Преглед изворни кода

Merge branch 'master' into php8

El RIDO пре 4 година
родитељ
комит
3f469f715f
100 измењених фајлова са 3816 додато и 777 уклоњено
  1. 3 0
      .gitattributes
  2. 49 0
      .github/workflows/codeql-analysis.yml
  3. 28 0
      .github/workflows/refresh-php8.yml
  4. 29 0
      .github/workflows/snyk-scan.yml
  5. 72 3
      .github/workflows/tests.yml
  6. 3 1
      .gitignore
  7. 36 0
      .scrutinizer.yml
  8. 30 1
      CHANGELOG.md
  9. 9 2
      CREDITS.md
  10. 32 15
      INSTALL.md
  11. 7 4
      Makefile
  12. 2 4
      README.md
  13. 2 2
      SECURITY.md
  14. 16 7
      cfg/conf.sample.php
  15. 49 0
      codacy-analysis.yml
  16. 6 2
      composer.json
  17. 137 52
      composer.lock
  18. 1 1
      css/bootstrap/privatebin.css
  19. 1 1
      css/noscript.css
  20. 5 1
      css/privatebin.css
  21. 5 2
      i18n/ar.json
  22. 5 2
      i18n/bg.json
  23. 189 0
      i18n/ca.json
  24. 5 2
      i18n/cs.json
  25. 5 2
      i18n/de.json
  26. 5 2
      i18n/el.json
  27. 5 2
      i18n/en.json
  28. 5 2
      i18n/es.json
  29. 189 0
      i18n/et.json
  30. 5 2
      i18n/fr.json
  31. 27 24
      i18n/he.json
  32. 5 2
      i18n/hi.json
  33. 5 2
      i18n/hu.json
  34. 189 0
      i18n/id.json
  35. 5 2
      i18n/it.json
  36. 5 2
      i18n/ja.json
  37. 5 2
      i18n/ku.json
  38. 5 2
      i18n/la.json
  39. 1 0
      i18n/languages.json
  40. 189 0
      i18n/lt.json
  41. 5 2
      i18n/nl.json
  42. 5 2
      i18n/no.json
  43. 6 3
      i18n/oc.json
  44. 5 2
      i18n/pl.json
  45. 5 2
      i18n/pt.json
  46. 6 3
      i18n/ru.json
  47. 5 2
      i18n/sl.json
  48. 5 2
      i18n/sv.json
  49. 31 28
      i18n/tr.json
  50. 5 2
      i18n/uk.json
  51. 32 29
      i18n/zh.json
  52. 1 1
      index.php
  53. 1 1
      js/common.js
  54. 0 1
      js/kjua-0.6.0.js
  55. 1 0
      js/kjua-0.9.0.js
  56. 1 1
      js/package.json
  57. 68 12
      js/privatebin.js
  58. 0 1
      js/purify-2.0.14.js
  59. 1 0
      js/purify-2.2.7.js
  60. 125 0
      js/test/TopNav.js
  61. 30 26
      lib/Configuration.php
  62. 22 21
      lib/Controller.php
  63. 57 8
      lib/Data/AbstractData.php
  64. 130 44
      lib/Data/Database.php
  65. 227 20
      lib/Data/Filesystem.php
  66. 346 0
      lib/Data/GoogleCloudStorage.php
  67. 1 1
      lib/Filter.php
  68. 6 1
      lib/FormatV2.php
  69. 9 3
      lib/I18n.php
  70. 4 4
      lib/Json.php
  71. 5 4
      lib/Model.php
  72. 1 1
      lib/Model/AbstractModel.php
  73. 1 1
      lib/Model/Comment.php
  74. 2 2
      lib/Model/Paste.php
  75. 8 89
      lib/Persistence/AbstractPersistence.php
  76. 0 97
      lib/Persistence/DataStore.php
  77. 9 14
      lib/Persistence/PurgeLimiter.php
  78. 12 29
      lib/Persistence/ServerSalt.php
  79. 70 24
      lib/Persistence/TrafficLimiter.php
  80. 8 11
      lib/Request.php
  81. 1 1
      lib/View.php
  82. 1 1
      lib/Vizhash16x16.php
  83. 14 5
      tpl/bootstrap.php
  84. 12 4
      tpl/page.php
  85. 591 1
      tst/Bootstrap.php
  86. 0 65
      tst/ConfigurationTest.php
  87. 0 4
      tst/ConfigurationTestGenerator.php
  88. 30 24
      tst/ControllerTest.php
  89. 4 0
      tst/ControllerWithDbTest.php
  90. 59 0
      tst/ControllerWithGcsTest.php
  91. 57 0
      tst/Data/DatabaseTest.php
  92. 1 0
      tst/Data/FilesystemTest.php
  93. 182 0
      tst/Data/GoogleCloudStorageTest.php
  94. 1 3
      tst/JsonApiTest.php
  95. 164 1
      tst/ModelTest.php
  96. 4 1
      tst/Persistence/PurgeLimiterTest.php
  97. 36 20
      tst/Persistence/ServerSaltTest.php
  98. 29 4
      tst/Persistence/TrafficLimiterTest.php
  99. 1 0
      tst/ViewTest.php
  100. 2 1
      tst/Vizhash16x16Test.php

+ 3 - 0
.gitattributes

@@ -16,8 +16,11 @@ js/test/ export-ignore
 .jshintrc export-ignore
 .nsprc export-ignore
 .php_cs export-ignore
+.scrutinizer.yml export-ignore
 .styleci.yml export-ignore
 .travis.yml export-ignore
+codacy-analysis.yml export-ignore
+crowdin.yml export-ignore
 composer.json export-ignore
 composer.lock export-ignore
 BADGES.md export-ignore

+ 49 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,49 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# Currently can only check JS.
+#
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ master ]
+  schedule:
+    - cron: '28 22 * * 5'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'javascript' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+        # Learn more:
+        # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1

+ 28 - 0
.github/workflows/refresh-php8.yml

@@ -0,0 +1,28 @@
+name: Refresh PHP 8 branch
+
+on:
+  push:
+    branches: [ master ]    
+  schedule:
+      - cron: '42 2 * * *'  
+  workflow_dispatch:
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Checkout php8 branch 
+        run: git checkout php8
+
+      - name: Merge master changes into php8
+        run: git merge master
+      
+      - name: Push new changes
+        uses: github-actions-x/commit@v2.8
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          push-branch: 'php8'
+

+ 29 - 0
.github/workflows/snyk-scan.yml

@@ -0,0 +1,29 @@
+# This is a basic workflow to help you get started with Actions
+
+name: Snyk scan
+
+on:
+  # Triggers the workflow on push or pull request events but only for the master branch
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+jobs:
+  # https://github.com/snyk/actions/tree/master/php
+  snyk-php:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@master
+      - name: Install Google Cloud Storage
+        run: composer require --no-update google/cloud-storage && composer update --no-dev
+      - name: Run Snyk to check for vulnerabilities
+        uses: snyk/actions/php@master
+        continue-on-error: true # To make sure that SARIF upload gets called
+        env:
+          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+        with:
+          args: --sarif-file-output=snyk.sarif
+      - name: Upload result to GitHub Code Scanning
+        uses: github/codeql-action/upload-sarif@v1
+        with:
+          sarif_file: snyk.sarif

+ 72 - 3
.github/workflows/tests.yml

@@ -2,6 +2,7 @@ name: Tests
 on: [push]
 
 jobs:
+
   Composer:
     runs-on: ubuntu-latest
     steps:
@@ -10,42 +11,110 @@ jobs:
     - name: Validate composer.json and composer.lock
       run: composer validate
     - name: Install dependencies
-      run: composer install --prefer-dist --no-progress --no-suggest
+      run: composer install --prefer-dist --no-dev
+      
   PHPunit:
     runs-on: ubuntu-latest
     strategy:
       matrix:
         php-versions: ['7.3', '7.4', '8.0']
     name: PHP ${{ matrix.php-versions }} unit tests on ${{ matrix.operating-system }}
+    env:
+      extensions: gd, sqlite3
+      extensions-cache-key-name: phpextensions
+      
     steps:
+    
+    # let's get started!
     - name: Checkout
       uses: actions/checkout@v2
+      
+    # cache PHP extensions
+    - name: Setup cache environment
+      id: extcache
+      uses: shivammathur/cache-extensions@v1
+      with:
+        php-version: ${{ matrix.php-versions }}
+        extensions: ${{ env.extensions }}
+        key: ${{ runner.os }}-${{ env.extensions-cache-key }}
+
+    - name: Cache extensions
+      uses: actions/cache@v2
+      with:
+        path: ${{ steps.extcache.outputs.dir }}
+        key: ${{ steps.extcache.outputs.key }}
+        restore-keys: ${{ runner.os }}-${{ env.extensions-cache-key }}
+      
     - name: Setup PHP
       uses: shivammathur/setup-php@v2
       with:
         php-version: ${{ matrix.php-versions }}
-        extensions: gd, sqlite3
+        extensions: ${{ env.extensions }}
+  
+    # Setup GitHub CI PHP problem matchers
+    # https://github.com/shivammathur/setup-php#problem-matchers
+    - name: Setup problem matchers for PHP
+      run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" 
+    
+    - name: Setup problem matchers for PHPUnit
+      run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+      
+    # composer cache
     - name: Remove composer lock
       run: rm composer.lock
+      
+    - name: Get composer cache directory
+      id: composer-cache
+      run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+    
+    # http://man7.org/linux/man-pages/man1/date.1.html
+    # https://github.com/actions/cache#creating-a-cache-key
+    - name: Get Date
+      id: get-date
+      run: |
+        echo "::set-output name=date::$(/bin/date -u "+%Y%m%d")"
+      shell: bash
+    
+    - name: Cache dependencies
+      uses: actions/cache@v2
+      with:
+        path: ${{ steps.composer-cache.outputs.dir }}
+        key: ${{ runner.os }}-composer-${{ steps.get-date.outputs.date }}-${{ hashFiles('**/composer.json') }}
+        restore-keys: ${{ runner.os }}-composer-${{ steps.get-date.outputs.date }}-
+    
+    # composer installation
     - name: Setup PHPunit
       run: composer install -n
+
+    - name: Install Google Cloud Storage
+      run: composer require google/cloud-storage
+    
+    # testing
     - name: Run unit tests
       run: ../vendor/bin/phpunit --no-coverage
       working-directory: tst
+      
   Mocha:
     runs-on: ubuntu-latest
     steps:
+    
     - name: Checkout
       uses: actions/checkout@v2
+      
     - name: Setup Node
-      uses: actions/setup-node@v1
+      uses: actions/setup-node@v2
       with:
         node-version: '12'
+        cache: 'npm'
+        cache-dependency-path: 'js/package.json'
+        
     - name: Setup Mocha
       run: npm install -g mocha
+      
     - name: Setup Node modules
       run: npm install
       working-directory: js
+      
     - name: Run unit tests
       run: mocha
       working-directory: js

+ 3 - 1
.gitignore

@@ -6,7 +6,7 @@ cfg/*
 !cfg/.htaccess
 
 # Ignore data/
-data/
+/data/
 
 # Ignore PhpDoc
 doc/*
@@ -36,3 +36,5 @@ tst/ConfigurationCombinationsTest.php
 .project
 .externalToolBuilders
 .c9
+/.idea/
+*.iml

+ 36 - 0
.scrutinizer.yml

@@ -0,0 +1,36 @@
+checks:
+    php: true
+    javascript: true
+filter:
+    paths:
+        - "css/privatebin.css"
+        - "css/bootstrap/privatebin.css"
+        - "js/privatebin.js"
+        - "lib/*.php"
+        - "index.php"
+coding_style:
+    php:
+        spaces:
+            around_operators:
+                additive: false
+                concatenation: true
+build:
+    environment:
+        php:
+            version: '7.2'
+    tests:
+        override:
+            -
+                command: 'composer require google/cloud-storage && cd tst && ../vendor/bin/phpunit'
+                coverage:
+                    file: 'tst/log/coverage-clover.xml'
+                    format: 'clover'
+    nodes:
+        tests: true
+        analysis:
+            tests:
+                override:
+                    -
+                        command: phpcs-run
+                        use_website_config: true
+                    - php-scrutinizer-run

+ 30 - 1
CHANGELOG.md

@@ -1,7 +1,36 @@
 # PrivateBin version history
 
   * **1.4 (not yet released)**
-    * CHANGED: Upgrading libraries to: DOMpurify 2.0.14
+    * ADDED: Translation for Estonian
+    * ADDED: new HTTP headers improving security (#765)
+    * ADDED: Download button for paste text (#774)
+    * ADDED: Opt-out of federated learning of cohorts (FLoC) (#776)
+    * ADDED: Configuration option to exempt IPs from the rate-limiter (#787)
+    * ADDED: Google Cloud Storage backend support (#795)
+    * CHANGED: Language selection cookie only transmitted over HTTPS (#472)
+    * CHANGED: Upgrading libraries to: random_compat 2.0.20
+    * CHANGED: Removed automatic `.ini` configuration file migration (#808)
+    * CHANGED: Removed configurable `dir` for `traffic` & `purge` limiters (#419)
+    * CHANGED: Server salt, traffic and purge limiter now stored in the storage backend (#419)
+  * **1.3.5 (2021-04-05)**
+    * ADDED: Translation for Hebrew, Lithuanian, Indonesian and Catalan
+    * ADDED: Make the project info configurable (#681)
+    * CHANGED: Upgrading libraries to: DOMpurify 2.2.7, kjua 0.9.0 & random_compat 2.0.18
+    * CHANGED: Open all links in new window (#630)
+    * FIXED: PDF display in Firefox (#630)
+    * FIXED: Allow pasting into password input dialog (#630)
+    * FIXED: Display of expiration date in email (#630)
+    * FIXED: Allow display of durations in weeks (#630)
+    * FIXED: Avoid exposing burn-after-reading messages from cache (#630)
+    * FIXED: Only display the dropzone when it should (#630)
+    * FIXED: Detect delete token properly (#630)
+    * FIXED: Sanitize output from `Helper.urls2links()` (#630)
+    * FIXED: Avoid recreation of existing pasteurl element when calling URL shortener (#630)
+    * FIXED: Downloads in Chrome >= 83 (#634)
+    * FIXED: Display of empty files (#663)
+    * FIXED: Improve OpenGraph attributes (#651)
+    * FIXED: Reset to configured burn-after-reading, discussion and expiration settings (#682)
+    * FIXED: Italic segment of project information (#756)
   * **1.3.4 (2020-03-22)**
     * CHANGED: Minimum required PHP version is 5.6, due to a change in the identicon library and to use php's native hash_equals()
     * CHANGED: Upgrading libraries to: identicon 2.0.0

+ 9 - 2
CREDITS.md

@@ -13,7 +13,7 @@ Sébastien Sauvage - original idea and main developer
 * Alexey Gladkov - syntax highlighting
 * Greg Knaddison - robots.txt
 * MrKooky - HTML5 markup, CSS cleanup
-* Simon Rupf - WebCrypto, unit tests, current docker containers, MVC, configuration, i18n
+* Simon Rupf - WebCrypto, unit tests, containers images, database backend, MVC, configuration, i18n
 * Hexalyse - Password protection
 * Viktor Stanchev - File upload support
 * azlux - Tab character input support
@@ -27,6 +27,8 @@ Sébastien Sauvage - original idea and main developer
 * Harald Leithner - base58 encoding of key
 * Haocen - lots of bugfixes and UI improvements
 * Lucas Savva - configurable config file location, NixOS packaging
+* rodehoed - option to exempt ips from the rate-limiter
+* Mark van Holsteijn - Google Cloud Storage backend
 
 ## Translations
 * Hexalyse - French
@@ -45,4 +47,9 @@ Sébastien Sauvage - original idea and main developer
 * Péter Tabajdi - Hungarian
 * info-path - Czech
 * BigWax - Bulgarian
-* AndriiZ - Ukrainian
+* AndriiZ - Ukrainian
+* Yaron Shahrabani - Hebrew
+* Moo - Lithuanian
+* whenwesober - Indonesian
+* retiolus - Catalan
+* sarnane - Estonian

+ 32 - 15
INSTALL.md

@@ -1,7 +1,7 @@
 # Installation
 
 **TL;DR:** Download the
-[latest release archive](https://github.com/PrivateBin/PrivateBin/releases/latest)
+[latest release archive](https://github.com/PrivateBin/PrivateBin/releases/latest) (with the link labelled as „Source code (…)“)
 and extract it in your web hosts folder where you want to install your PrivateBin
 instance. We try to provide a mostly safe default configuration, but we urge you to
 check the [security section](#hardening-and-security) below and the [configuration
@@ -9,21 +9,21 @@ options](#configuration) to adjust as you see fit.
 
 **NOTE:** See [our FAQ](https://github.com/PrivateBin/PrivateBin/wiki/FAQ#how-can-i-securely-clonedownload-your-project) for information how to securely download the PrivateBin release files.
 
-### Minimal requirements
+**NOTE:** There is a [ansible](https://ansible.com) role by @e1mo available to install and configure PrivateBin on your server. It's available on [ansible galaxy](https://galaxy.ansible.com/e1mo/privatebin) ([source code](https://git.sr.ht/~e1mo/ansible-role-privatebin)).
 
-- PHP version 5.5 or above
-- _one_ of the following sources of cryptographically safe randomness is required:
-  - PHP 7 or higher
-  - [Libsodium](https://download.libsodium.org/libsodium/content/installation/) and it's [PHP extension](https://paragonie.com/book/pecl-libsodium/read/00-intro.md#installing-libsodium)
-  - open_basedir access to `/dev/urandom`
-  - mcrypt extension
-  - com_dotnet extension
+### Minimal requirements
 
-  Mcrypt needs to be able to access `/dev/urandom`. This means if `open_basedir` is set, it must include this file.
+- PHP version 7.0 or above
+  - Or PHP version 5.6 AND _one_ of the following sources of cryptographically safe randomness:
+    - [Libsodium](https://download.libsodium.org/libsodium/content/installation/) and it's [PHP extension](https://paragonie.com/book/pecl-libsodium/read/00-intro.md#installing-libsodium)
+    - open_basedir access to `/dev/urandom`
+    - mcrypt extension (mcrypt needs to be able to access `/dev/urandom`. This means if `open_basedir` is set, it must include this file.)
+    - com_dotnet extension
 - GD extension
-- some disk space or (optionally) a database supported by [PDO](https://secure.php.net/manual/book.pdo.php)
+- zlib extension
+- some disk space or (optionally) a database supported by [PDO](https://php.net/manual/book.pdo.php)
 - ability to create files and folders in the installation directory and the PATH defined in index.php
-- A web browser with javascript support
+- A web browser with JavaScript support
 
 ## Hardening and security
 
@@ -74,7 +74,7 @@ Note that your PHP process will need read access to the config wherever it may b
 ### Transport security
 
 When setting up PrivateBin, also set up HTTPS, if you haven't already. Without HTTPS
-PrivateBin is not secure, as the javascript files could be manipulated during transmission.
+PrivateBin is not secure, as the JavaScript files could be manipulated during transmission.
 For more information on this, see our [FAQ entry on HTTPS setup](https://github.com/PrivateBin/PrivateBin/wiki/FAQ#how-should-i-setup-https).
 
 ### File-level permissions
@@ -187,7 +187,24 @@ CREATE INDEX parent ON prefix_comment(pasteid);
 CREATE TABLE prefix_config (
     id CHAR(16) NOT NULL, value TEXT, PRIMARY KEY (id)
 );
-INSERT INTO prefix_config VALUES('VERSION', '1.3.4');
+INSERT INTO prefix_config VALUES('VERSION', '1.3.5');
 ```
 
-In **PostgreSQL**, the data, attachment, nickname and vizhash columns needs to be TEXT and not BLOB or MEDIUMBLOB.
+In **PostgreSQL**, the data, attachment, nickname and vizhash columns needs to
+be TEXT and not BLOB or MEDIUMBLOB.
+
+### Using Google Cloud Storage
+If you want to deploy PrivateBin in a serverless manner in the Google Cloud, you
+can choose the `GoogleCloudStorage` as backend. To use this backend, you create
+a GCS bucket and specify the name as the model option `bucket`. Alternatively,
+you can set the name through the environment variable PASTEBIN_GCS_BUCKET.
+
+The default prefix for pastes stored in the bucket is `pastes`. To change the
+prefix, specify the option `prefix`.
+
+Google Cloud Storage buckets may be significantly slower than a `FileSystem` or
+`Database` backend. The big advantage is that the deployment on Google Cloud
+Platform using Google Cloud Run is easy and cheap.
+
+To use the Google Cloud Storage backend you have to install the suggested
+library using the command `composer require google/cloud-storage`.

+ 7 - 4
Makefile

@@ -1,13 +1,16 @@
 .PHONY: all coverage coverage-js coverage-php doc doc-js doc-php increment sign test test-js test-php help
 
-CURRENT_VERSION = 1.3.4
-VERSION ?= 1.3.5
-VERSION_FILES = index.php cfg/ *.md css/ i18n/ img/ js/privatebin.js lib/ Makefile tpl/ tst/
+CURRENT_VERSION = 1.3.5
+VERSION ?= 1.3.6
+VERSION_FILES = index.php cfg/ *.md css/ i18n/ img/ js/package.json js/privatebin.js lib/ Makefile tpl/ tst/
 REGEX_CURRENT_VERSION := $(shell echo $(CURRENT_VERSION) | sed "s/\./\\\./g")
 REGEX_VERSION := $(shell echo $(VERSION) | sed "s/\./\\\./g")
 
 all: coverage doc ## Equivalent to running `make coverage doc`.
 
+composer: ## Update composer dependencies (only production ones, optimize the autoloader)
+	composer update --no-dev --optimize-autoloader
+
 coverage: coverage-js coverage-php ## Run all unit tests and generate code coverage reports.
 
 coverage-js: ## Run JS unit tests and generate code coverage reports.
@@ -15,7 +18,7 @@ coverage-js: ## Run JS unit tests and generate code coverage reports.
 
 coverage-php: ## Run PHP unit tests and generate code coverage reports.
 	cd tst && phpunit 2> /dev/null
-	cd log/php-coverage-report && sed -i "s#$(CURDIR)##g" *.html */*.html
+	cd tst/log/php-coverage-report && sed -i "s#$(CURDIR)##g" *.html */*.html
 
 doc: doc-js doc-php ## Generate all code documentation.
 

+ 2 - 4
README.md

@@ -1,6 +1,6 @@
 # [![PrivateBin](https://cdn.rawgit.com/PrivateBin/assets/master/images/preview/logoSmall.png)](https://privatebin.info/)
 
-*Current version: 1.3.4*
+*Current version: 1.3.5*
 
 **PrivateBin** is a minimalist, open source online [pastebin](https://en.wikipedia.org/wiki/Pastebin)
 where the server has zero knowledge of pasted data.
@@ -37,9 +37,7 @@ without losing any data.
   Otherwise you would also have to trust your internet provider, and any country
   the traffic passes through.
   Additionally the instance should be secured by
-  [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) and
-  ideally by [HPKP](https://en.wikipedia.org/wiki/HTTP_Public_Key_Pinning) using a
-  certificate. It can use traditional certificate authorities and/or use
+  [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security). It can use traditional certificate authorities and/or use
   [DNSSEC](https://en.wikipedia.org/wiki/Domain_Name_System_Security_Extensions)
   protected
   [DANE](https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities)

+ 2 - 2
SECURITY.md

@@ -4,8 +4,8 @@
 
 | Version | Supported          |
 | ------- | ------------------ |
-| 1.3.4   | :heavy_check_mark: |
-| < 1.3.4 | :x:                |
+| 1.3.5   | :heavy_check_mark: |
+| < 1.3.5 | :x:                |
 
 ## Reporting a Vulnerability
 

+ 16 - 7
cfg/conf.sample.php

@@ -39,6 +39,10 @@ sizelimit = 10485760
 ; template to include, default is "bootstrap" (tpl/bootstrap.php)
 template = "bootstrap"
 
+; (optional) info text to display
+; use single, instead of double quotes for HTML attributes
+;info = "More information on the <a href='https://privatebin.info/'>project page</a>."
+
 ; (optional) notice to display
 ; notice = "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service."
 
@@ -83,7 +87,7 @@ languageselection = false
 ;   async functions and display an error if not and for Chrome to enable
 ;   webassembly support (used for zlib compression). You can remove it if Chrome
 ;   doesn't need to be supported and old browsers don't need to be warned.
-; cspheader = "default-src 'none'; manifest-src 'self'; connect-src * blob:; script-src 'self' 'unsafe-eval' resource:; style-src 'self'; font-src 'self'; img-src 'self' data: blob:; media-src blob:; object-src blob:; sandbox allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads"
+; cspheader = "default-src 'none'; base-uri 'self'; form-action 'none'; manifest-src 'self'; connect-src * blob:; script-src 'self' 'unsafe-eval' resource:; style-src 'self'; font-src 'self'; img-src 'self' data: blob:; media-src blob:; object-src blob:; sandbox allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads"
 
 ; stay compatible with PrivateBin Alpha 0.19, less secure
 ; if enabled will use base64.js version 1.7 instead of 2.1.9 and sha1 instead of
@@ -131,13 +135,14 @@ markdown = "Markdown"
 ; Set this to 0 to disable rate limiting.
 limit = 10
 
+; Set ips (v4|v6) which should be exempted for the rate-limit. CIDR also supported. Needed to be comma separated.
+; Unset for enabling and invalid values will be ignored
+; eg: exemptedIp = '1.2.3.4,10.10.10/24'
+
 ; (optional) if your website runs behind a reverse proxy or load balancer,
 ; set the HTTP header containing the visitors IP address, i.e. X_FORWARDED_FOR
 ; header = "X_FORWARDED_FOR"
 
-; directory to store the traffic limits in
-dir = PATH "data"
-
 [purge]
 ; minimum time limit between two purgings of expired pastes, it is only
 ; triggered when pastes are created
@@ -149,9 +154,6 @@ limit = 300
 ; site
 batchsize = 10
 
-; directory to store the purge limit in
-dir = PATH "data"
-
 [model]
 ; name of data model class to load and directory for storage
 ; the default model "Filesystem" stores everything in the filesystem
@@ -159,6 +161,13 @@ class = Filesystem
 [model_options]
 dir = PATH "data"
 
+;[model]
+; example of a Google Cloud Storage configuration
+;class = GoogleCloudStorage
+;[model_options]
+;bucket = "my-private-bin"
+;prefix = "pastes"
+
 ;[model]
 ; example of DB configuration for MySQL
 ;class = Database

+ 49 - 0
codacy-analysis.yml

@@ -0,0 +1,49 @@
+# This workflow checks out code, performs a Codacy security scan
+# and integrates the results with the
+# GitHub Advanced Security code scanning feature.  For more information on
+# the Codacy security scan action usage and parameters, see
+# https://github.com/codacy/codacy-analysis-cli-action.
+# For more information on Codacy Analysis CLI in general, see
+# https://github.com/codacy/codacy-analysis-cli.
+
+name: Codacy Security Scan
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ master ]
+  schedule:
+    - cron: '45 16 * * 1'
+
+jobs:
+  codacy-security-scan:
+    name: Codacy Security Scan
+    runs-on: ubuntu-latest
+    steps:
+      # Checkout the repository to the GitHub Actions runner
+      - name: Checkout code
+        uses: actions/checkout@v2
+
+      # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
+      - name: Run Codacy Analysis CLI
+        uses: codacy/codacy-analysis-cli-action@1.1.0
+        with:
+          # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository
+          # You can also omit the token and run the tools that support default configurations
+          project-token: ${{ secrets.CODACY_PROJECT_TOKEN }}
+          verbose: true
+          output: results.sarif
+          format: sarif
+          # Adjust severity of non-security issues
+          gh-code-scanning-compat: true
+          # Force 0 exit code to allow SARIF file generation
+          # This will handover control about PR rejection to the GitHub side
+          max-allowed-issues: 2147483647
+
+      # Upload the SARIF file generated in the previous step
+      - name: Upload SARIF results file
+        uses: github/codeql-action/upload-sarif@v1
+        with:
+          sarif_file: results.sarif

+ 6 - 2
composer.json

@@ -26,7 +26,11 @@
 	"require" : {
 		"php" : "^5.6.0 || ^7.0 || ^8.0",
 		"paragonie/random_compat" : "<9.99",
-		"yzalis/identicon" : "^2.0.0"
+		"yzalis/identicon" : "^2.0.0",
+		"mlocati/ip-lib" : "1.14.0"
+	},
+	"suggest" : {
+		"google/cloud-storage" : "1.23.1"
 	},
 	"require-dev" : {
 		"phpunit/phpunit" : "^9"
@@ -39,4 +43,4 @@
 	"config" : {
 		"autoloader-suffix" : "DontChange"
 	}
-}
+}

+ 137 - 52
composer.lock

@@ -4,20 +4,88 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "f8f346748370d0efd672270571c4aa74",
+    "content-hash": "217f0ba9bdac1014a332a8ba390be949",
     "packages": [
+        {
+            "name": "mlocati/ip-lib",
+            "version": "1.14.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/mlocati/ip-lib.git",
+                "reference": "882bc0e115970a536b13bcfa59f312783fce08c8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/mlocati/ip-lib/zipball/882bc0e115970a536b13bcfa59f312783fce08c8",
+                "reference": "882bc0e115970a536b13bcfa59f312783fce08c8",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "require-dev": {
+                "ext-pdo_sqlite": "*",
+                "phpunit/dbunit": "^1.4 || ^2 || ^3 || ^4",
+                "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "IPLib\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Michele Locati",
+                    "email": "mlocati@gmail.com",
+                    "homepage": "https://github.com/mlocati",
+                    "role": "Author"
+                }
+            ],
+            "description": "Handle IPv4, IPv6 addresses and ranges",
+            "homepage": "https://github.com/mlocati/ip-lib",
+            "keywords": [
+                "IP",
+                "address",
+                "addresses",
+                "ipv4",
+                "ipv6",
+                "manage",
+                "managing",
+                "matching",
+                "network",
+                "networking",
+                "range",
+                "subnet"
+            ],
+            "funding": [
+                {
+                    "url": "https://github.com/sponsors/mlocati",
+                    "type": "github"
+                },
+                {
+                    "url": "https://paypal.me/mlocati",
+                    "type": "other"
+                }
+            ],
+            "time": "2020-12-31T11:30:02+00:00"
+        },
         {
             "name": "paragonie/random_compat",
-            "version": "v2.0.18",
+            "version": "v2.0.20",
             "source": {
                 "type": "git",
                 "url": "https://github.com/paragonie/random_compat.git",
-                "reference": "0a58ef6e3146256cc3dc7cc393927bcc7d1b72db"
+                "reference": "0f1f60250fccffeaf5dda91eea1c018aed1adc2a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/0a58ef6e3146256cc3dc7cc393927bcc7d1b72db",
-                "reference": "0a58ef6e3146256cc3dc7cc393927bcc7d1b72db",
+                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/0f1f60250fccffeaf5dda91eea1c018aed1adc2a",
+                "reference": "0f1f60250fccffeaf5dda91eea1c018aed1adc2a",
                 "shasum": ""
             },
             "require": {
@@ -53,7 +121,7 @@
                 "pseudorandom",
                 "random"
             ],
-            "time": "2019-01-03T20:59:08+00:00"
+            "time": "2021-04-17T09:33:01+00:00"
         },
         {
             "name": "yzalis/identicon",
@@ -105,42 +173,38 @@
                 "identicon",
                 "image"
             ],
+            "abandoned": true,
             "time": "2019-10-14T09:30:57+00:00"
         }
     ],
     "packages-dev": [
         {
             "name": "doctrine/instantiator",
-            "version": "1.3.1",
+            "version": "1.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/instantiator.git",
-                "reference": "f350df0268e904597e3bd9c4685c53e0e333feea"
+                "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/f350df0268e904597e3bd9c4685c53e0e333feea",
-                "reference": "f350df0268e904597e3bd9c4685c53e0e333feea",
+                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b",
+                "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.1 || ^8.0"
             },
             "require-dev": {
-                "doctrine/coding-standard": "^6.0",
+                "doctrine/coding-standard": "^8.0",
                 "ext-pdo": "*",
                 "ext-phar": "*",
-                "phpbench/phpbench": "^0.13",
-                "phpstan/phpstan-phpunit": "^0.11",
-                "phpstan/phpstan-shim": "^0.11",
-                "phpunit/phpunit": "^7.0"
+                "phpbench/phpbench": "^0.13 || 1.0.0-alpha2",
+                "phpstan/phpstan": "^0.12",
+                "phpstan/phpstan-phpunit": "^0.12",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.2.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
@@ -154,7 +218,7 @@
                 {
                     "name": "Marco Pivetta",
                     "email": "ocramius@gmail.com",
-                    "homepage": "http://ocramius.github.com/"
+                    "homepage": "https://ocramius.github.io/"
                 }
             ],
             "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
@@ -177,20 +241,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-29T17:27:14+00:00"
+            "time": "2020-11-10T18:47:58+00:00"
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.10.1",
+            "version": "1.10.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5"
+                "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
-                "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220",
+                "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220",
                 "shasum": ""
             },
             "require": {
@@ -1088,15 +1152,15 @@
                     "role": "lead"
                 }
             ],
-            "description": "Collection of value objects that represent the PHP code units",
-            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
                 }
             ],
-            "time": "2020-10-02T14:47:54+00:00"
+            "time": "2020-11-30T08:15:22+00:00"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",
@@ -1896,20 +1960,20 @@
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.18.1",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "1c302646f6efc070cd46856e600e5e0684d6b454"
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454",
-                "reference": "1c302646f6efc070cd46856e600e5e0684d6b454",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.1"
             },
             "suggest": {
                 "ext-ctype": "For best performance"
@@ -1917,7 +1981,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.18-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
@@ -1968,20 +2032,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-07-14T12:35:20+00:00"
+            "time": "2021-02-19T12:13:01+00:00"
         },
         {
-            "name": "theseer/tokenizer",
-            "version": "1.2.0",
+            "name": "symfony/yaml",
+            "version": "v4.4.24",
             "source": {
                 "type": "git",
-                "url": "https://github.com/theseer/tokenizer.git",
-                "reference": "75a63c33a8577608444246075ea0af0d052e452a"
+                "url": "https://github.com/symfony/yaml.git",
+                "reference": "8b6d1b97521e2f125039b3fcb4747584c6dfa0ef"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/75a63c33a8577608444246075ea0af0d052e452a",
-                "reference": "75a63c33a8577608444246075ea0af0d052e452a",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6d1b97521e2f125039b3fcb4747584c6dfa0ef",
+                "reference": "8b6d1b97521e2f125039b3fcb4747584c6dfa0ef",
                 "shasum": ""
             },
             "require": {
@@ -2014,34 +2078,55 @@
                     "type": "github"
                 }
             ],
-            "time": "2020-07-12T23:59:07+00:00"
+            "description": "Loads and dumps YAML files",
+            "homepage": "https://symfony.com",
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-05-16T09:52:47+00:00"
         },
         {
             "name": "webmozart/assert",
-            "version": "1.9.1",
+            "version": "1.10.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/webmozart/assert.git",
-                "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389"
+                "url": "https://github.com/webmozarts/assert.git",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389",
-                "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389",
+                "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.3.3 || ^7.0 || ^8.0",
+                "php": "^7.2 || ^8.0",
                 "symfony/polyfill-ctype": "^1.8"
             },
             "conflict": {
                 "phpstan/phpstan": "<0.12.20",
-                "vimeo/psalm": "<3.9.1"
+                "vimeo/psalm": "<4.6.1 || 4.6.2"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.36 || ^7.5.13"
+                "phpunit/phpunit": "^8.5.13"
             },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.10-dev"
+                }
+            },
             "autoload": {
                 "psr-4": {
                     "Webmozart\\Assert\\": "src/"
@@ -2063,7 +2148,7 @@
                 "check",
                 "validate"
             ],
-            "time": "2020-07-08T17:02:28+00:00"
+            "time": "2021-03-09T10:59:23+00:00"
         }
     ],
     "aliases": [],

+ 1 - 1
css/bootstrap/privatebin.css

@@ -6,7 +6,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 body {

+ 1 - 1
css/noscript.css

@@ -6,7 +6,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 /* When there is no script at all other */

+ 5 - 1
css/privatebin.css

@@ -6,7 +6,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 /*  CSS Reset from YUI 3.4.1 (build 4118) - Copyright 2011 Yahoo! Inc. All rights reserved.
@@ -249,6 +249,10 @@ button img {
 	padding: 1px 0 1px 0;
 }
 
+#downloadtextbutton img {
+	padding: 1px 0 1px 0;
+}
+
 #remainingtime, #password {
 	color: #94a3b4;
 	display: inline;

+ 5 - 2
i18n/ar.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "ar",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/bg.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s е изчистен и изцяло достъпен като отворен код, онлайн \"paste\" услуга, където сървъра не знае подадената информация. Тя се шифрова/дешифрова <i>във браузъра</i> използвайки 256 битов AES алгоритъм. Повече информация може да намерите на <a href=\"https://privatebin.info/\">страницата на проекта (Английски)</a>",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s е изчистен и изцяло достъпен като отворен код, онлайн \"paste\" услуга, където сървъра не знае подадената информация. Тя се шифрова/дешифрова %sвъв браузъра%s използвайки 256 битов AES алгоритъм.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Повече информация може да намерите на <a href=\"https://privatebin.info/\">страницата на проекта (Английски)</a>.",
     "Because ignorance is bliss": "Невежеството е блаженство",
     "en": "bg",
     "Paste does not exist, has expired or has been deleted.": "Информацията не съществува, срокът и е изтекъл или е била изтрита.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 189 - 0
i18n/ca.json

@@ -0,0 +1,189 @@
+{
+    "PrivateBin": "PrivateBin",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s és un pastebin en línia de codi obert i minimalista on el servidor no té coneixement de les dades enganxades. Les dades estan encriptades/desxifrades %sen el navegador%s utilitzant AES de 256 bits.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Més informació a la <a href=\"https://privatebin.info/\">pàgina del projecte</a>.",
+    "Because ignorance is bliss": "Perquè la ignorància és felicitat",
+    "en": "ca",
+    "Paste does not exist, has expired or has been deleted.": "El paste no existeix, ha caducat o s'ha eliminat.",
+    "%s requires php %s or above to work. Sorry.": "%s requereix php %s o superior per funcionar. Ho sento.",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s requereix que la secció de configuració [%s] sigui present al fitxer de configuració.",
+    "Please wait %d seconds between each post.": [
+        "Espereu %d segon entre cada entrada. (singular)",
+        "Espereu %d segons entre cada entrada. (1r plural)",
+        "Espereu %d segons entre cada entrada. (2n plural)",
+        "Please wait %d seconds between each post. (3er plural)"
+    ],
+    "Paste is limited to %s of encrypted data.": "L'enganxat està limitat a %s de dades encriptades.",
+    "Invalid data.": "Dades no vàlides.",
+    "You are unlucky. Try again.": "Mala sort. Torna-ho a provar.",
+    "Error saving comment. Sorry.": "S'ha produït un error en desar el comentari. Ho sento.",
+    "Error saving paste. Sorry.": "S'ha produït un error en desar l'enganxat. Ho sento.",
+    "Invalid paste ID.": "Identificador d'enganxament no vàlid.",
+    "Paste is not of burn-after-reading type.": "Paste is not of burn-after-reading type.",
+    "Wrong deletion token. Paste was not deleted.": "Wrong deletion token. Paste was not deleted.",
+    "Paste was properly deleted.": "Paste was properly deleted.",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "Cal JavaScript perquè %s funcioni. Em sap greu les molèsties.",
+    "%s requires a modern browser to work.": "%s requereix un navegador modern per funcionar.",
+    "New": "Nou",
+    "Send": "Enviar",
+    "Clone": "Clona",
+    "Raw text": "Text sense processar",
+    "Expires": "Caducitat",
+    "Burn after reading": "Esborra després de ser llegit",
+    "Open discussion": "Discussió oberta",
+    "Password (recommended)": "Contrasenya (recomanat)",
+    "Discussion": "Discussió",
+    "Toggle navigation": "Alternar navegació",
+    "%d seconds": [
+        "%d second (singular)",
+        "%d seconds (1st plural)",
+        "%d seconds (2nd plural)",
+        "%d seconds (3rd plural)"
+    ],
+    "%d minutes": [
+        "%d minute (singular)",
+        "%d minutes (1st plural)",
+        "%d minutes (2nd plural)",
+        "%d minutes (3rd plural)"
+    ],
+    "%d hours": [
+        "%d hour (singular)",
+        "%d hours (1st plural)",
+        "%d hours (2nd plural)",
+        "%d hours (3rd plural)"
+    ],
+    "%d days": [
+        "%d day (singular)",
+        "%d days (1st plural)",
+        "%d days (2nd plural)",
+        "%d days (3rd plural)"
+    ],
+    "%d weeks": [
+        "%d week (singular)",
+        "%d weeks (1st plural)",
+        "%d weeks (2nd plural)",
+        "%d weeks (3rd plural)"
+    ],
+    "%d months": [
+        "%d month (singular)",
+        "%d months (1st plural)",
+        "%d months (2nd plural)",
+        "%d months (3rd plural)"
+    ],
+    "%d years": [
+        "%d year (singular)",
+        "%d years (1st plural)",
+        "%d years (2nd plural)",
+        "%d years (3rd plural)"
+    ],
+    "Never": "Never",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.",
+    "This document will expire in %d seconds.": [
+        "This document will expire in %d second. (singular)",
+        "This document will expire in %d seconds. (1st plural)",
+        "This document will expire in %d seconds. (2nd plural)",
+        "This document will expire in %d seconds. (3rd plural)"
+    ],
+    "This document will expire in %d minutes.": [
+        "This document will expire in %d minute. (singular)",
+        "This document will expire in %d minutes. (1st plural)",
+        "This document will expire in %d minutes. (2nd plural)",
+        "This document will expire in %d minutes. (3rd plural)"
+    ],
+    "This document will expire in %d hours.": [
+        "This document will expire in %d hour. (singular)",
+        "This document will expire in %d hours. (1st plural)",
+        "This document will expire in %d hours. (2nd plural)",
+        "This document will expire in %d hours. (3rd plural)"
+    ],
+    "This document will expire in %d days.": [
+        "This document will expire in %d day. (singular)",
+        "This document will expire in %d days. (1st plural)",
+        "This document will expire in %d days. (2nd plural)",
+        "This document will expire in %d days. (3rd plural)"
+    ],
+    "This document will expire in %d months.": [
+        "This document will expire in %d month. (singular)",
+        "This document will expire in %d months. (1st plural)",
+        "This document will expire in %d months. (2nd plural)",
+        "This document will expire in %d months. (3rd plural)"
+    ],
+    "Please enter the password for this paste:": "Please enter the password for this paste:",
+    "Could not decrypt data (Wrong key?)": "Could not decrypt data (Wrong key?)",
+    "Could not delete the paste, it was not stored in burn after reading mode.": "Could not delete the paste, it was not stored in burn after reading mode.",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.",
+    "Could not decrypt comment; Wrong key?": "Could not decrypt comment; Wrong key?",
+    "Reply": "Reply",
+    "Anonymous": "Anonymous",
+    "Avatar generated from IP address": "Avatar generated from IP address",
+    "Add comment": "Add comment",
+    "Optional nickname…": "Optional nickname…",
+    "Post comment": "Post comment",
+    "Sending comment…": "Sending comment…",
+    "Comment posted.": "Comment posted.",
+    "Could not refresh display: %s": "Could not refresh display: %s",
+    "unknown status": "unknown status",
+    "server error or not responding": "server error or not responding",
+    "Could not post comment: %s": "Could not post comment: %s",
+    "Sending paste…": "Sending paste…",
+    "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>": "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>",
+    "Delete data": "Delete data",
+    "Could not create paste: %s": "Could not create paste: %s",
+    "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
+    "B": "B",
+    "KiB": "KiB",
+    "MiB": "MiB",
+    "GiB": "GiB",
+    "TiB": "TiB",
+    "PiB": "PiB",
+    "EiB": "EiB",
+    "ZiB": "ZiB",
+    "YiB": "YiB",
+    "Format": "Format",
+    "Plain Text": "Plain Text",
+    "Source Code": "Source Code",
+    "Markdown": "Markdown",
+    "Download attachment": "Download attachment",
+    "Cloned: '%s'": "Cloned: '%s'",
+    "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
+    "Attach a file": "Attach a file",
+    "alternatively drag & drop a file or paste an image from the clipboard": "alternatively drag & drop a file or paste an image from the clipboard",
+    "File too large, to display a preview. Please download the attachment.": "File too large, to display a preview. Please download the attachment.",
+    "Remove attachment": "Remove attachment",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "Your browser does not support uploading encrypted files. Please use a newer browser.",
+    "Invalid attachment.": "Invalid attachment.",
+    "Options": "Options",
+    "Shorten URL": "Shorten URL",
+    "Editor": "Editor",
+    "Preview": "Preview",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.",
+    "Decrypt": "Decrypt",
+    "Enter password": "Enter password",
+    "Loading…": "Loading…",
+    "Decrypting paste…": "Decrypting paste…",
+    "Preparing new paste…": "Preparing new paste…",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.",
+    "+++ no paste text +++": "+++ no paste text +++",
+    "Could not get paste data: %s": "Could not get paste data: %s",
+    "QR code": "QR code",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "This website is using an insecure HTTP connection! Please use it only for testing.",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "For more information <a href=\"%s\">see this FAQ entry</a>.",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.",
+    "waiting on user to provide a password": "waiting on user to provide a password",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.",
+    "Retry": "Retry",
+    "Showing raw text…": "Showing raw text…",
+    "Notice:": "Notice:",
+    "This link will expire after %s.": "This link will expire after %s.",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "This link can only be accessed once, do not use back or refresh button in your browser.",
+    "Link:": "Link:",
+    "Recipient may become aware of your timezone, convert time to UTC?": "Recipient may become aware of your timezone, convert time to UTC?",
+    "Use Current Timezone": "Use Current Timezone",
+    "Convert To UTC": "Convert To UTC",
+    "Close": "Close",
+    "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
+}

+ 5 - 2
i18n/cs.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s je minimalistický open source 'pastebin' server, který neanalyzuje vložená data. Data jsou šifrována <i>v prohlížeči</i> pomocí 256 bitů AES. Více informací na <a href=\"https://privatebin.info/\">stránce projetu</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s je minimalistický open source 'pastebin' server, který neanalyzuje vložená data. Data jsou šifrována %sv prohlížeči%s pomocí 256 bitů AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Více informací na <a href=\"https://privatebin.info/\">stránce projetu</a>.",
     "Because ignorance is bliss": "Protože nevědomost je sladká",
     "en": "cs",
     "Paste does not exist, has expired or has been deleted.": "Vložený text neexistuje, expiroval nebo byl odstraněn.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/de.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s ist ein minimalistischer, quelloffener \"Pastebin\"-artiger Dienst, bei dem der Server keinerlei Kenntnis der Inhalte hat. Die Daten werden <i>im Browser</i> mit 256 Bit AES ver- und entschlüsselt. Weitere Informationen sind auf der <a href=\"https://privatebin.info/\">Projektseite</a> zu finden.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s ist ein minimalistischer, quelloffener \"Pastebin\"-artiger Dienst, bei dem der Server keinerlei Kenntnis der Inhalte hat. Die Daten werden %sim Browser%s mit 256 Bit AES ver- und entschlüsselt.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Weitere Informationen sind auf der <a href=\"https://privatebin.info/\">Projektseite</a> zu finden.",
     "Because ignorance is bliss": "Unwissenheit ist ein Segen",
     "en": "de",
     "Paste does not exist, has expired or has been deleted.": "Diesen Text gibt es nicht, er ist abgelaufen oder wurde gelöscht.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "In UTC umwandeln",
     "Close": "Schliessen",
     "Encrypted note on PrivateBin": "Verschlüsselte Notiz auf PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Besuche diese Verknüpfung um das Dokument zu sehen. Wird die URL an eine andere Person gegeben, so kann diese Person ebenfalls auf dieses Dokument zugreifen."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Besuche diese Verknüpfung um das Dokument zu sehen. Wird die URL an eine andere Person gegeben, so kann diese Person ebenfalls auf dieses Dokument zugreifen.",
+    "URL shortener may expose your decrypt key in URL.": "Der URL-Verkürzer kann den Schlüssel in der URL enthüllen.",
+    "Save paste": "Text speichern"
 }

+ 5 - 2
i18n/el.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "el",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/en.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "en",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/es.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s es un \"pastebin\" en línea minimalista de código abierto, donde el servidor no tiene ningún conocimiento de los datos guardados. Los datos son cifrados/descifrados <i>en el navegador</i> usando 256 bits AES. Más información en la <a href=\"https://privatebin.info/\">página del proyecto</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s es un \"pastebin\" en línea minimalista de código abierto, donde el servidor no tiene ningún conocimiento de los datos guardados. Los datos son cifrados/descifrados %sen el navegador%s usando 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Más información en la <a href=\"https://privatebin.info/\">página del proyecto</a>.",
     "Because ignorance is bliss": "Porque la ignorancia es dicha",
     "en": "es",
     "Paste does not exist, has expired or has been deleted.": "El \"paste\" no existe, ha caducado o ha sido eliminado.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convertir A UTC",
     "Close": "Cerrar",
     "Encrypted note on PrivateBin": "Nota cifrada en PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visite este enlace para ver la nota. Dar la URL a cualquier persona también les permite acceder a la nota."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visite este enlace para ver la nota. Dar la URL a cualquier persona también les permite acceder a la nota.",
+    "URL shortener may expose your decrypt key in URL.": "El acortador de URL puede exponer su clave de descifrado en el URL.",
+    "Save paste": "Guardar \"paste\""
 }

+ 189 - 0
i18n/et.json

@@ -0,0 +1,189 @@
+{
+    "PrivateBin": "PrivateBin",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s on minimalistlik, avatud lähtekoodiga online pastebin, kus serveril pole kleebitud andmete kohta teadmist. Andmed krüpteeritakse/dekrüpteeritakse %sbrauseris%s kasutades 256-bitist AES-i.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Lisateave <a href=\"https://privatebin.info/\">projekti lehel</a>.",
+    "Because ignorance is bliss": "Kuna teadmatus on õndsus",
+    "en": "et",
+    "Paste does not exist, has expired or has been deleted.": "Kleebet ei eksisteeri, on aegunud või on kustutatud.",
+    "%s requires php %s or above to work. Sorry.": "%s vajab, et oleks php %s või kõrgem, et töötada. Vabandame.",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s vajab, et [%s] seadistamise jaotis oleks olemas konfiguratsioonifailis.",
+    "Please wait %d seconds between each post.": [
+        "Palun oota %d sekund iga postituse vahel.",
+        "Palun oota %d sekundit iga postituse vahel.",
+        "Palun oota %d sekundit iga postituse vahel.",
+        "Palun oota %d sekundit iga postituse vahel."
+    ],
+    "Paste is limited to %s of encrypted data.": "Kleepe limiit on %s krüpteeritud andmeid.",
+    "Invalid data.": "Valed andmed.",
+    "You are unlucky. Try again.": "Sul ei vea. Proovi uuesti.",
+    "Error saving comment. Sorry.": "Viga kommentaari salvestamisel. Vabandame.",
+    "Error saving paste. Sorry.": "Viga kleepe salvestamisel. Vabandame.",
+    "Invalid paste ID.": "Vale kleepe ID.",
+    "Paste is not of burn-after-reading type.": "Kleebe ei ole põleta-pärast-lugemist tüüpi.",
+    "Wrong deletion token. Paste was not deleted.": "Vale kustutamiskood. Kleebet ei kustutatud.",
+    "Paste was properly deleted.": "Kleebe kustutati korralikult.",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "JavaScript on vajalik %s'i töötamiseks. Vabandame ebamugavuste pärast.",
+    "%s requires a modern browser to work.": "%s vajab töötamiseks kaasaegset brauserit.",
+    "New": "Uus",
+    "Send": "Saada",
+    "Clone": "Klooni",
+    "Raw text": "Lähtetekst",
+    "Expires": "Aegub",
+    "Burn after reading": "Põleta pärast lugemist",
+    "Open discussion": "Avatud arutelu",
+    "Password (recommended)": "Parool (soovitatav)",
+    "Discussion": "Arutelu",
+    "Toggle navigation": "Näita menüüd",
+    "%d seconds": [
+        "%d sekund",
+        "%d sekundit",
+        "%d sekundit",
+        "%d sekundit"
+    ],
+    "%d minutes": [
+        "%d minut",
+        "%d minutit",
+        "%d minutit",
+        "%d minutit"
+    ],
+    "%d hours": [
+        "%d tund",
+        "%d tundi",
+        "%d tundi",
+        "%d tundi"
+    ],
+    "%d days": [
+        "%d päev",
+        "%d päeva",
+        "%d päeva",
+        "%d päeva"
+    ],
+    "%d weeks": [
+        "%d nädal",
+        "%d nädalat",
+        "%d nädalat",
+        "%d nädalat"
+    ],
+    "%d months": [
+        "%d kuu",
+        "%d kuud",
+        "%d kuud",
+        "%d kuud"
+    ],
+    "%d years": [
+        "%d aasta",
+        "%d aastat",
+        "%d aastat",
+        "%d aastat"
+    ],
+    "Never": "Mitte kunagi",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Märge: See on testimisteenus: Andmeid võidakse igal ajal kustutada. Kiisupojad hukuvad, kui seda teenust kuritarvitad.",
+    "This document will expire in %d seconds.": [
+        "See dokument aegub %d sekundi pärast.",
+        "See dokument aegub %d sekundi pärast.",
+        "See dokument aegub %d sekundi pärast.",
+        "See dokument aegub %d sekundi pärast."
+    ],
+    "This document will expire in %d minutes.": [
+        "See dokument aegub %d minuti pärast.",
+        "See dokument aegub %d minuti pärast.",
+        "See dokument aegub %d minuti pärast.",
+        "See dokument aegub %d minuti pärast."
+    ],
+    "This document will expire in %d hours.": [
+        "See dokument aegub %d tunni pärast.",
+        "See dokument aegub %d tunni pärast.",
+        "See dokument aegub %d tunni pärast.",
+        "See dokument aegub %d tunni pärast."
+    ],
+    "This document will expire in %d days.": [
+        "See dokument aegub %d päeva pärast.",
+        "See dokument aegub %d päeva pärast.",
+        "See dokument aegub %d päeva pärast.",
+        "See dokument aegub %d päeva pärast."
+    ],
+    "This document will expire in %d months.": [
+        "See dokument aegub %d kuu pärast.",
+        "See dokument aegub %d kuu pärast.",
+        "See dokument aegub %d kuu pärast.",
+        "See dokument aegub %d kuu pärast."
+    ],
+    "Please enter the password for this paste:": "Palun sisesta selle kleepe parool:",
+    "Could not decrypt data (Wrong key?)": "Ei suutnud andmeid dekrüpteerida (Vale võti?)",
+    "Could not delete the paste, it was not stored in burn after reading mode.": "Ei suutnud kleebet kustutada, seda ei salvestatud põleta pärast lugemist režiimis.",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "AINULT SINU SILMADELE. Ära sulge seda akent, seda sõnumit ei saa enam kuvada.",
+    "Could not decrypt comment; Wrong key?": "Ei suutnud kommentaari dekrüpteerida; Vale võti?",
+    "Reply": "Vasta",
+    "Anonymous": "Anonüümne",
+    "Avatar generated from IP address": "Avatar genereeritud IP aadressi põhjal",
+    "Add comment": "Lisa kommentaar",
+    "Optional nickname…": "Valikuline hüüdnimi…",
+    "Post comment": "Postita kommentaar",
+    "Sending comment…": "Kommentaari saatmine…",
+    "Comment posted.": "Kommentaar postitatud.",
+    "Could not refresh display: %s": "Ei suutnud kuva värskendada: %s",
+    "unknown status": "tundmatu staatus",
+    "server error or not responding": "serveri viga või ei vasta",
+    "Could not post comment: %s": "Ei suutnud kommentaari postitada: %s",
+    "Sending paste…": "Kleepe saatmine…",
+    "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>": "Sinu kleebe on <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Kopeerimiseks vajuta [Ctrl]+[c])</span>",
+    "Delete data": "Kustuta andmed",
+    "Could not create paste: %s": "Ei suutnud kleebet luua: %s",
+    "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Ei suutnud kleebet dekrüpteerida: Dekrüpteerimisvõti on URL-ist puudu (Kas kasutasid ümbersuunajat või URL-i lühendajat, mis eemaldab osa URL-ist?)",
+    "B": "B",
+    "KiB": "KiB",
+    "MiB": "MiB",
+    "GiB": "GiB",
+    "TiB": "TiB",
+    "PiB": "PiB",
+    "EiB": "EiB",
+    "ZiB": "ZiB",
+    "YiB": "YiB",
+    "Format": "Formaat",
+    "Plain Text": "Lihttekst",
+    "Source Code": "Lähtekood",
+    "Markdown": "Markdown",
+    "Download attachment": "Laadi manus alla",
+    "Cloned: '%s'": "Kloonitud: '%s'",
+    "The cloned file '%s' was attached to this paste.": "Kloonitud fail '%s' manustati sellele kleepele.",
+    "Attach a file": "Manusta fail",
+    "alternatively drag & drop a file or paste an image from the clipboard": "teise võimalusena lohista fail või kleebi pilt lõikelaualt",
+    "File too large, to display a preview. Please download the attachment.": "Fail on eelvaate kuvamiseks liiga suur. Palun laadi manus alla.",
+    "Remove attachment": "Eemalda manus",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "Sinu brauser ei toeta krüpteeritud failide üleslaadimist. Palun kasuta uuemat brauserit.",
+    "Invalid attachment.": "Sobimatu manus.",
+    "Options": "Valikud",
+    "Shorten URL": "Lühenda URL",
+    "Editor": "Toimetaja",
+    "Preview": "Eelvaade",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s vajab, et PATH lõppeks järgmisega: \"%s\". Palun uuenda PATH-i oma index.php failis.",
+    "Decrypt": "Dekrüpteeri",
+    "Enter password": "Sisesta parool",
+    "Loading…": "Laadimine…",
+    "Decrypting paste…": "Kleepe dekrüpteerimine…",
+    "Preparing new paste…": "Uue kleepe ettevalmistamine…",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "Kui see sõnum ei kao, palun vaata <a href=\"%s\">seda KKK-d, et saada tõrkeotsinguks teavet.</a>.",
+    "+++ no paste text +++": "+++ kleepe tekst puudub +++",
+    "Could not get paste data: %s": "Ei suutnud saada kleepe andmeid: %s",
+    "QR code": "QR kood",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "See veebisait kasutab ebaturvalist HTTP ühendust! Palun kasuta seda ainult katsetamiseks.",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "Lisateabe saamiseks <a href=\"%s\">vaata seda KKK sissekannet</a>.",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "Sinu brauser võib vajada HTTPS ühendust, et toetada WebCrypto API-d. Proovi <a href=\"%s\">üle minna HTTPS-ile</a>.",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "Sinu brauser ei toeta WebAssembly't, mida kasutatakse zlib tihendamiseks. Sa saad luua tihendamata dokumente, kuid ei saa lugeda tihendatuid.",
+    "waiting on user to provide a password": "ootan parooli sisestamist kasutajalt",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "Ei suutnud andmeid dekrüpteerida. Kas sisestasid vale parooli? Proovi uuesti üleval asuva nupuga.",
+    "Retry": "Proovi uuesti",
+    "Showing raw text…": "Lähteteksti näitamine…",
+    "Notice:": "Teade:",
+    "This link will expire after %s.": "See link aegub: %s.",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "Sellele lingile saab vaid üks kord ligi pääseda, ära kasuta tagasi või värskenda nuppe sinu brauseris.",
+    "Link:": "Link:",
+    "Recipient may become aware of your timezone, convert time to UTC?": "Saaja võib saada teada sinu ajavööndi, kas teisendada aeg UTC-ks?",
+    "Use Current Timezone": "Kasuta praegust ajavööndit",
+    "Convert To UTC": "Teisenda UTC-ks",
+    "Close": "Sulge",
+    "Encrypted note on PrivateBin": "Krüpteeritud kiri PrivateBin-is",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Kirja nägemiseks külasta seda linki. Teistele URL-i andmine lubab ka neil ligi pääseda kirjale.",
+    "URL shortener may expose your decrypt key in URL.": "URL-i lühendaja võib paljastada sinu dekrüpteerimisvõtme URL-is.",
+    "Save paste": "Salvesta kleebe"
+}

+ 5 - 2
i18n/fr.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s est un 'pastebin' (ou gestionnaire d'extraits de texte et de code source) minimaliste et open source, dans lequel le serveur n'a aucune connaissance des données envoyées. Les données sont chiffrées/déchiffrées <i>dans le navigateur</i> par un chiffrement AES 256 bits. Plus d'informations sur <a href=\"https://privatebin.info/\">la page du projet</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s est un 'pastebin' (ou gestionnaire d'extraits de texte et de code source) minimaliste et open source, dans lequel le serveur n'a aucune connaissance des données envoyées. Les données sont chiffrées/déchiffrées %sdans le navigateur%s par un chiffrement AES 256 bits.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Plus d'informations sur <a href=\"https://privatebin.info/\">la page du projet</a>.",
     "Because ignorance is bliss": "Parce que l'ignorance c'est le bonheur",
     "en": "fr",
     "Paste does not exist, has expired or has been deleted.": "Le paste n'existe pas, a expiré, ou a été supprimé.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convertir en UTC",
     "Close": "Fermer",
     "Encrypted note on PrivateBin": "Message chiffré sur PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visiter ce lien pour voir la note. Donner l'URL à une autre personne lui permet également d'accéder à la note."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visiter ce lien pour voir la note. Donner l'URL à une autre personne lui permet également d'accéder à la note.",
+    "URL shortener may expose your decrypt key in URL.": "Raccourcir l'URL peut exposer votre clé de déchiffrement dans l'URL.",
+    "Save paste": "Sauver le paste"
 }

+ 27 - 24
i18n/he.json

@@ -1,10 +1,11 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "כיוון שבורות היא ברכה",
     "en": "he",
     "Paste does not exist, has expired or has been deleted.": "ההדבקה לא קיימת, פגה או נמחקה.",
-    "%s requires php %s or above to work. Sorry.": "%s requires php %s or above to work. Sorry.",
+    "%s requires php %s or above to work. Sorry.": "%s דורש PHP %s כדי לפעול.",
     "%s requires configuration section [%s] to be present in configuration file.": "%s דורש שסעיף ההגדרות [%s] יהיה קיים בקובץ ההגדרות.",
     "Please wait %d seconds between each post.": [
         "נא להמתין שנייה אחת בין פרסום לפרסום.",
@@ -22,7 +23,7 @@
     "Wrong deletion token. Paste was not deleted.": "אסימון מחיקה שגוי. ההדבקה לא נמחקה.",
     "Paste was properly deleted.": "ההדבקה נמחקה כראוי.",
     "JavaScript is required for %s to work. Sorry for the inconvenience.": "צריך JavaScript כדי לאפשר ל־%s לפעול. סליחה על חוסר הנוחות.",
-    "%s requires a modern browser to work.": "%s requires a modern browser to work.",
+    "%s requires a modern browser to work.": "%s דורש דפדפן מודרני כדי לפעול.",
     "New": "חדש",
     "Send": "שליחה",
     "Clone": "שכפול",
@@ -52,31 +53,31 @@
         "%d hours (3rd plural)"
     ],
     "%d days": [
-        "%d day (singular)",
-        "%d days (1st plural)",
-        "%d days (2nd plural)",
-        "%d days (3rd plural)"
+        "יום אחד",
+        "%d ימים",
+        "%d ימים",
+        "%d ימים"
     ],
     "%d weeks": [
-        "%d week (singular)",
-        "%d weeks (1st plural)",
-        "%d weeks (2nd plural)",
-        "%d weeks (3rd plural)"
+        "שבוע אחד",
+        "%d שבועות",
+        "%d שבועות",
+        "%d שבועות"
     ],
     "%d months": [
-        "%d month (singular)",
-        "%d months (1st plural)",
-        "%d months (2nd plural)",
-        "%d months (3rd plural)"
+        "חודש אחד",
+        "%d חודשים",
+        "%d חודשים",
+        "%d חודשים"
     ],
     "%d years": [
-        "%d year (singular)",
-        "%d years (1st plural)",
-        "%d years (2nd plural)",
-        "%d years (3rd plural)"
+        "שנה אחת",
+        "%d שנים",
+        "%d שנים",
+        "%d שנים"
     ],
     "Never": "לעולם לא",
-    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "הערה: זהו שירות בדקה: המידע לא ישמר.",
     "This document will expire in %d seconds.": [
         "This document will expire in %d second. (singular)",
         "This document will expire in %d seconds. (1st plural)",
@@ -112,7 +113,7 @@
     "Could not delete the paste, it was not stored in burn after reading mode.": "לא ניתן למחוק את ההדבקה, היא לא אוחסנה במצב קוראים-שורפים.",
     "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "לעיניך בלבד. לא לסגור את החלון הזה, ההודעה הזאת לא תופיע שוב.",
     "Could not decrypt comment; Wrong key?": "לא ניתן לפענח את ההערה, מפתח שגוי?",
-    "Reply": "Reply",
+    "Reply": "תגובה",
     "Anonymous": "אלמוני",
     "Avatar generated from IP address": "התמונה הייצוגית נוצרה מכתובת ה־IP",
     "Add comment": "הוספת תגובה",
@@ -138,12 +139,12 @@
     "EiB": "EiB",
     "ZiB": "ZiB",
     "YiB": "YiB",
-    "Format": "Format",
+    "Format": "פורמט",
     "Plain Text": "טקסט פשוט",
     "Source Code": "קוד מקור",
     "Markdown": "Markdown",
     "Download attachment": "הורדת קובץ מצורף",
-    "Cloned: '%s'": "Cloned: '%s'",
+    "Cloned: '%s'": "שוכפל: '%s'",
     "The cloned file '%s' was attached to this paste.": "The cloned file '%s' was attached to this paste.",
     "Attach a file": "צירוף קובץ",
     "alternatively drag & drop a file or paste an image from the clipboard": "alternatively drag & drop a file or paste an image from the clipboard",
@@ -182,5 +183,7 @@
     "Convert To UTC": "המרה ל־UTC",
     "Close": "סגירה",
     "Encrypted note on PrivateBin": "הערה מוצפנת ב־PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "נא לבקר בקישור כדי לצפות בהערה. מסירת הקישור לאנשים כלשהם תאפשר גם להם לגשת להערה."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "נא לבקר בקישור כדי לצפות בהערה. מסירת הקישור לאנשים כלשהם תאפשר גם להם לגשת להערה.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/hi.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "hi",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/hu.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "A %s egy minimalista, nyílt forráskódú adattároló szoftver, ahol a szerver semmilyen információt nem tárol a feltett adatról. Azt ugyanis a <i>böngésződ</i> segítségével titkosítja és oldja fel 256 bit hosszú titkosítási kulcsú AES-t használva. További információt a <a href=\"https://privatebin.info/\">projekt oldalán</a> találsz.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "A %s egy minimalista, nyílt forráskódú adattároló szoftver, ahol a szerver semmilyen információt nem tárol a feltett adatról. Azt ugyanis a %sböngésződ%s segítségével titkosítja és oldja fel 256 bit hosszú titkosítási kulcsú AES-t használva.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "További információt a <a href=\"https://privatebin.info/\">projekt oldalán</a> találsz.",
     "Because ignorance is bliss": "A titok egyfajta hatalom.",
     "en": "hu",
     "Paste does not exist, has expired or has been deleted.": "A bejegyzés nem létezik, lejárt vagy törölve lett.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Átalakítás UTC időzónára",
     "Close": "Bezárás",
     "Encrypted note on PrivateBin": "Titkosított jegyzet a PrivateBinen",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Látogasd meg ezt a hivatkozást a bejegyzés megtekintéséhez. Ha mások számára is megadod ezt a linket, azzal hozzáférnek ők is."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Látogasd meg ezt a hivatkozást a bejegyzés megtekintéséhez. Ha mások számára is megadod ezt a linket, azzal hozzáférnek ők is.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 189 - 0
i18n/id.json

@@ -0,0 +1,189 @@
+{
+    "PrivateBin": "PrivateBin",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s adalah sebuah pastebin online sumber terbuka dan minimalis, dimana servernya tersebut tidak punya pengetahuan tentang data yang ditempelkan. Data tersebut di enkrip/dekrip %sdi dalam browser%s menggunakan metode enkrip AES 256 bit.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Infomasi lebih lanjut pada <a href=\"https://privatebin.info/\">halaman proyek</a>.",
+    "Because ignorance is bliss": "Karena ketidaktahuan adalah kebahagiaan, gitu loh",
+    "en": "id",
+    "Paste does not exist, has expired or has been deleted.": "Paste tidak ada, telah kedaluwarsa atau telah dihapus.",
+    "%s requires php %s or above to work. Sorry.": "%s memerlukan php %s atau versi diatasnya untuk dapat dijalankan. Maaf.",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s membutuhkan bagian konfigurasi [%s] untuk ada di file konfigurasi.",
+    "Please wait %d seconds between each post.": [
+        "Silahkan menunggu %d detik antara masing-masing postingan.",
+        "Silahkan menunggu %d detik antara masing-masing postingan.",
+        "Silahkan menunggu %d detik antara masing-masing postingan.",
+        "Silahkan menunggu %d detik antara masing-masing postingan."
+    ],
+    "Paste is limited to %s of encrypted data.": "Paste dibatasi sampai %s dari data yang dienskripsi.",
+    "Invalid data.": "Data tidak valid.",
+    "You are unlucky. Try again.": "Anda belum beruntung. Coba kembali ya Kaka.",
+    "Error saving comment. Sorry.": "Terjadi kesalahan saat menyimpan komentar. Maaf ya Kaka.",
+    "Error saving paste. Sorry.": "Terjadi kesalahan saat menyimpan paste. Maaf ya Kaka.",
+    "Invalid paste ID.": "ID paste tidak valid.",
+    "Paste is not of burn-after-reading type.": "Paste bukan tipe hapus-setelah-membaca.",
+    "Wrong deletion token. Paste was not deleted.": "Token penghapusan salah. Paste belum terhapus.",
+    "Paste was properly deleted.": "Paste telah dihapus dengan benar.",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "JavaScript diperlukan agar %s bekerja. Maaf untuk ketidaknyamanannya.",
+    "%s requires a modern browser to work.": "%s memerlukan sebuah browser modern untuk bekerja.",
+    "New": "Baru",
+    "Send": "Kirim",
+    "Clone": "Klon",
+    "Raw text": "Teks mentah",
+    "Expires": "Kadaluarsa",
+    "Burn after reading": "Hapus setelah membaca",
+    "Open discussion": "Diskusi terbuka",
+    "Password (recommended)": "Kata Sandi (direkomendasikan)",
+    "Discussion": "Diskusi",
+    "Toggle navigation": "Alihkan navigasi",
+    "%d seconds": [
+        "%d detik",
+        "%d detik",
+        "%d detik",
+        "%d detik"
+    ],
+    "%d minutes": [
+        "%d menit",
+        "%d menit",
+        "%d menit",
+        "%d menit"
+    ],
+    "%d hours": [
+        "%d jam",
+        "%d jam",
+        "%d jam",
+        "%d jam"
+    ],
+    "%d days": [
+        "%d hari",
+        "%d hari",
+        "%d hari",
+        "%d hari"
+    ],
+    "%d weeks": [
+        "%d minggu",
+        "%d minggu",
+        "%d minggu",
+        "%d minggu"
+    ],
+    "%d months": [
+        "%d bulan",
+        "%d bulan",
+        "%d bulan",
+        "%d bulan"
+    ],
+    "%d years": [
+        "%d tahun",
+        "%d tahun",
+        "%d tahun",
+        "%d tahun"
+    ],
+    "Never": "Jangan pernah",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Catatan: Ini adalah layanan percobaan: Data mungkin bisa terhapus kapanpun juga. Anak-anak kucing akan mati jika Anda mengekploitasi layanan ini.",
+    "This document will expire in %d seconds.": [
+        "Dokumen ini kadaluarsa dalam %d detik.",
+        "Dokumen ini kadaluarsa dalam %d detik.",
+        "Dokumen ini kadaluarsa dalam %d detik.",
+        "Dokumen ini kadaluarsa dalam %d detik."
+    ],
+    "This document will expire in %d minutes.": [
+        "Dokumen ini akan kadaluarsa dalam %d menit.",
+        "Dokumen ini akan kadaluarsa dalam %d menit.",
+        "Dokumen ini akan kadaluarsa dalam %d menit.",
+        "Dokumen ini akan kadaluarsa dalam %d menit."
+    ],
+    "This document will expire in %d hours.": [
+        "Dokumen ini akan kadaluarsa dalam %d jam.",
+        "Dokumen ini akan kadaluarsa dalam %d jam.",
+        "Dokumen ini akan kadaluarsa dalam %d jam.",
+        "Dokumen ini akan kadaluarsa dalam %d jam."
+    ],
+    "This document will expire in %d days.": [
+        "Dokumen ini akan kadaluarsa dalam %d hari.",
+        "Dokumen ini akan kadaluarsa dalam %d hari.",
+        "Dokumen ini akan kadaluarsa dalam %d hari.",
+        "Dokumen ini akan kadaluarsa dalam %d hari."
+    ],
+    "This document will expire in %d months.": [
+        "Dokumen ini akan kadaluarsa dalam %d bulan.",
+        "Dokumen ini akan kadaluarsa dalam %d bulan.",
+        "Dokumen ini akan kadaluarsa dalam %d bulan.",
+        "Dokumen ini akan kadaluarsa dalam %d bulan."
+    ],
+    "Please enter the password for this paste:": "Silahkan masukkan kata sandi untuk paste ini:",
+    "Could not decrypt data (Wrong key?)": "Tidak dapat mendekrip data (Salah kunci?)",
+    "Could not delete the paste, it was not stored in burn after reading mode.": "Tidak dapat menghapus paste, ini dikarenakan data tidak tersimpan dalam mode hapus setelah membaca.",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "HANYA UNTUK ANDA SAJA. Jangan tutup kolom jendela ini, pesan ini tidak akan dapat ditampilkan lagi.",
+    "Could not decrypt comment; Wrong key?": "Tidak dapat mendekrip komentar; Salah kunci?",
+    "Reply": "Balas",
+    "Anonymous": "Tanpa Nama",
+    "Avatar generated from IP address": "Avatar dihasilkan dari alamat IP",
+    "Add comment": "Tambah komentar",
+    "Optional nickname…": "Nama julukan tambahan…",
+    "Post comment": "Posting komentar",
+    "Sending comment…": "Mengirim komentar…",
+    "Comment posted.": "Komentar telah diposting.",
+    "Could not refresh display: %s": "Tidak dapat menyegarkan tampilan: %s",
+    "unknown status": "status tidak diketahui",
+    "server error or not responding": "kesalahan server atau server tidak merespon",
+    "Could not post comment: %s": "Tidak dapat memposting komentar: %s",
+    "Sending paste…": "Mengirim paste…",
+    "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>": "Paste Anda adalah <a id=\"pasteurl\" href=\"%s\">%s</a><span id=\"copyhint\">(Tekan [Ctrl]+[c] untuk menyalin)</span>",
+    "Delete data": "Hapus data",
+    "Could not create paste: %s": "Tidak dapat membuat paste: %s",
+    "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Tidak dapat mendekripsi paste: Kunci dekripsi tidak ada di URL (Apakah Anda menggunakan redirector atau penyingkat URL yang menghapus bagian dari URL?)",
+    "B": "B",
+    "KiB": "KiB",
+    "MiB": "MiB",
+    "GiB": "GiB",
+    "TiB": "TiB",
+    "PiB": "PiB",
+    "EiB": "EiB",
+    "ZiB": "ZiB",
+    "YiB": "YiB",
+    "Format": "Format",
+    "Plain Text": "Teks Biasa",
+    "Source Code": "Kode Sumber",
+    "Markdown": "Markdown",
+    "Download attachment": "Unduh lampiran",
+    "Cloned: '%s'": "Diklon: '%s'",
+    "The cloned file '%s' was attached to this paste.": "Berkas yang di-klon '%s' telah dilampirkan pada paste ini.",
+    "Attach a file": "Lampirkan sebuah berkas",
+    "alternatively drag & drop a file or paste an image from the clipboard": "sebagai alternatif, seret & jatuhkan berkas atau tempel sebuah gambar dari papan klip",
+    "File too large, to display a preview. Please download the attachment.": "File terlalu besar untuk menampilkan pratinjau. Silakan unduh lampirannya.",
+    "Remove attachment": "Hapus lampiran",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "Browser Anda tidak mendukung pengunggahan file terenkripsi. Harap gunakan browser yang lebih baru.",
+    "Invalid attachment.": "Lampiran tidak valid.",
+    "Options": "Pilihan",
+    "Shorten URL": "Pendekkan alamat URL",
+    "Editor": "Penyunting",
+    "Preview": "Pratinjau",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s memerlukan PATH berakhir dalam sebuah \"%s\". Silahkan perbarui PATH dalam index.php Anda.",
+    "Decrypt": "Dekrip",
+    "Enter password": "Masukkan kata sandi",
+    "Loading…": "Memuat…",
+    "Decrypting paste…": "Men-dekrip paste…",
+    "Preparing new paste…": "Menyiapkan paste baru…",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "Jika pesan ini tidak pernah menghilang, silahkan kunjungi dan lihat pada <a href=\"%s\">FAQ ini untuk informasi bagaimana menyelesaikan masalah tersebut</a>.",
+    "+++ no paste text +++": "+++ tidak ada teks paste +++",
+    "Could not get paste data: %s": "Tidak dapat mengambil/menampilkan data paste: %s",
+    "QR code": "Kode QR",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "Situs web ini menggunakan koneksi HTTP yang tidak aman! Silahkan gunakan hanya untuk pengujian.",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "Untuk informasi lebih lanjut, <a href=\"%s\"> lihat entri FAQ ini </a>.",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "Browser Anda mungkin memerlukan koneksi HTTPS untuk mendukung API Webcrypto. Coba <a href=\"%s\"> beralih ke HTTPS </a>.",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "Browser Anda tidak mendukung Webassembly, yang digunakan untuk kompresi zlib. Anda dapat membuat dokumen yang tidak terkompresi, tetapi tidak akan dapat membaca berkas yang terkompresi.",
+    "waiting on user to provide a password": "menunggu pengguna untuk menyediakan kata sandi",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "Tidak dapat mendekrip data. Apakah Anda memasukkan kata sandi yang salah? Silahkan coba lagi dengan tombol di bagian atas.",
+    "Retry": "Coba lagi",
+    "Showing raw text…": "Menampilkan teks mentah…",
+    "Notice:": "Pengumuman:",
+    "This link will expire after %s.": "Tautan ini akan kadaluarsa setelah %s.",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "Tautan ini hanya dapat diakses satu kali, jangan gunakan tombol Kembali atau tombol Segarkan di browser Anda.",
+    "Link:": "Tautan:",
+    "Recipient may become aware of your timezone, convert time to UTC?": "Penerima dapat mengetahui zona waktu Anda, ubah waktu menjadi UTC?",
+    "Use Current Timezone": "Gunakan Zonawaktu Saat Ini",
+    "Convert To UTC": "Konversi Ke UTC",
+    "Close": "Tutup",
+    "Encrypted note on PrivateBin": "Catatan ter-ekrip di PrivateBin",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Kunjungi tautan ini untuk melihat catatan. Memberikan alamat URL pada siapapun juga, akan mengizinkan mereka untuk mengakses catatan, so pasti gitu loh Kaka.",
+    "URL shortener may expose your decrypt key in URL.": "Pemendek URL mungkin akan menampakkan kunci dekrip Anda dalam URL.",
+    "Save paste": "Simpan paste"
+}

+ 5 - 2
i18n/it.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s è un sistema di tipo \"Pastebin\" online, open source, minimalista. Il server non possiede alcuna conoscenza (\"Zero Knowledge\") del contenuto dei dati inviati. I dati sono cifrati/decifrati <i>nel Browser</i> con algoritmo AES a 256 Bit. Per ulteriori informazioni, vedi <a href=\"https://privatebin.info/\">Sito del progetto</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s è un sistema di tipo \"Pastebin\" online, open source, minimalista. Il server non possiede alcuna conoscenza (\"Zero Knowledge\") del contenuto dei dati inviati. I dati sono cifrati/decifrati %snel Browser%s con algoritmo AES a 256 Bit.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Per ulteriori informazioni, vedi <a href=\"https://privatebin.info/\">Sito del progetto</a>.",
     "Because ignorance is bliss": "Perché l'ignoranza è una benedizione (Because ignorance is bliss)",
     "en": "it",
     "Paste does not exist, has expired or has been deleted.": "Questo messaggio non esiste, è scaduto o è stato cancellato.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Converti a UTC",
     "Close": "Chiudi",
     "Encrypted note on PrivateBin": "Nota crittografata su PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visita questo collegamento per vedere la nota. Dare l'URL a chiunque consente anche a loro di accedere alla nota."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visita questo collegamento per vedere la nota. Dare l'URL a chiunque consente anche a loro di accedere alla nota.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener può esporre la tua chiave decrittografata nell'URL.",
+    "Save paste": "Salva il messagio"
 }

+ 5 - 2
i18n/ja.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "ja",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/ku.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "ku",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/la.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivatumVinariam",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "la",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 1 - 0
i18n/languages.json

@@ -63,6 +63,7 @@
     "ho": ["Hiri Motu", "Hiri Motu"],
     "hu": ["magyar", "Hungarian"],
     "ia": ["Interlingua", "Interlingua"],
+    "id": ["bahasa Indonesia","Indonesian"],
     "ie": ["Interlingue", "Interlingue"],
     "ga": ["Gaeilge", "Irish"],
     "ig": ["Asụsụ Igbo", "Igbo"],

+ 189 - 0
i18n/lt.json

@@ -0,0 +1,189 @@
+{
+    "PrivateBin": "PrivateBin",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s yra minimalistinis, atvirojo kodo internetinis įdėjimų dėklas, kurį naudojant, serveris nieko nenutuokia apie įdėtus duomenis. Duomenys yra šifruojami/iššifruojami %snaršyklėje%s naudojant 256 bitų AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Daugiau informacijos rasite <a href=\"https://privatebin.info/\">projekto puslapyje</a>.",
+    "Because ignorance is bliss": "Nes nežinojimas yra palaima",
+    "en": "lt",
+    "Paste does not exist, has expired or has been deleted.": "Įdėjimo nėra, jis nebegalioja arba buvo ištrintas.",
+    "%s requires php %s or above to work. Sorry.": "%s savo darbui reikalauja php %s arba naujesnės versijos. Apgailestaujame.",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s reikalauja, kad konfigūracijos faile būtų [%s] konfigūracijos sekcija.",
+    "Please wait %d seconds between each post.": [
+        "Tarp kiekvieno įrašo palaukite %d sekundę.",
+        "Tarp kiekvieno įrašo palaukite %d sekundes.",
+        "Tarp kiekvieno įrašo palaukite %d sekundžių.",
+        "Tarp kiekvieno įrašo palaukite %d sekundę."
+    ],
+    "Paste is limited to %s of encrypted data.": "Įdėjimas yra apribotas iki %s šifruotų duomenų.",
+    "Invalid data.": "Neteisingi duomenys.",
+    "You are unlucky. Try again.": "Jums nesiseka. Bandykite dar kartą.",
+    "Error saving comment. Sorry.": "Klaida įrašant komentarą. Apgailestaujame.",
+    "Error saving paste. Sorry.": "Klaida įrašant įdėjimą. Apgailestaujame.",
+    "Invalid paste ID.": "Neteisingas įdėjimo ID.",
+    "Paste is not of burn-after-reading type.": "Įdėjimo tipas nėra „Perskaičius sudeginti“.",
+    "Wrong deletion token. Paste was not deleted.": "Neteisingas ištrynimo prieigos raktas. Įdėjimas nebuvo ištrintas.",
+    "Paste was properly deleted.": "Įdėjimas buvo tinkamai ištrintas.",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "%s darbui reikalinga JavaScript. Atsiprašome už nepatogumus.",
+    "%s requires a modern browser to work.": "%s savo darbui reikalauja šiuolaikinės naršyklės.",
+    "New": "Naujas",
+    "Send": "Siųsti",
+    "Clone": "Dubliuoti",
+    "Raw text": "Neapdorotas tekstas",
+    "Expires": "Baigs galioti po",
+    "Burn after reading": "Perskaičius sudeginti",
+    "Open discussion": "Atvira diskusija",
+    "Password (recommended)": "Slaptažodis (rekomenduojama)",
+    "Discussion": "Diskusija",
+    "Toggle navigation": "Perjungti naršymą",
+    "%d seconds": [
+        "%d sekundės",
+        "%d sekundžių",
+        "%d sekundžių",
+        "%d sekundės"
+    ],
+    "%d minutes": [
+        "%d minutės",
+        "%d minučių",
+        "%d minučių",
+        "%d minutės"
+    ],
+    "%d hours": [
+        "%d valandos",
+        "%d valandų",
+        "%d valandų",
+        "%d valandos"
+    ],
+    "%d days": [
+        "%d dienos",
+        "%d dienų",
+        "%d dienų",
+        "%d dienos"
+    ],
+    "%d weeks": [
+        "%d savaitės",
+        "%d savaičių",
+        "%d savaičių",
+        "%d savaitės"
+    ],
+    "%d months": [
+        "%d mėnesio",
+        "%d mėnesių",
+        "%d mėnesių",
+        "%d mėnesio"
+    ],
+    "%d years": [
+        "%d metų",
+        "%d metų",
+        "%d metų",
+        "%d metų"
+    ],
+    "Never": "Niekada",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Pastaba: Tai yra bandomoji paslauga. Duomenys bet kuriuo metu gali būti ištrinti. Kačiukai mirs, jei piktnaudžiausite šia paslauga.",
+    "This document will expire in %d seconds.": [
+        "Šis dokumentas nustos galioti po %d sekundės.",
+        "Šis dokumentas nustos galioti po %d sekundžių.",
+        "Šis dokumentas nustos galioti po %d sekundžių.",
+        "Šis dokumentas nustos galioti po %d sekundės."
+    ],
+    "This document will expire in %d minutes.": [
+        "Šis dokumentas nustos galioti po %d minutės.",
+        "Šis dokumentas nustos galioti po %d minučių.",
+        "Šis dokumentas nustos galioti po %d minučių.",
+        "Šis dokumentas nustos galioti po %d minutės."
+    ],
+    "This document will expire in %d hours.": [
+        "Šis dokumentas nustos galioti po %d valandos.",
+        "Šis dokumentas nustos galioti po %d valandų.",
+        "Šis dokumentas nustos galioti po %d valandų.",
+        "Šis dokumentas nustos galioti po %d valandos."
+    ],
+    "This document will expire in %d days.": [
+        "Šis dokumentas nustos galioti po %d dienos.",
+        "Šis dokumentas nustos galioti po %d dienų.",
+        "Šis dokumentas nustos galioti po %d dienų.",
+        "Šis dokumentas nustos galioti po %d dienos."
+    ],
+    "This document will expire in %d months.": [
+        "Šis dokumentas nustos galioti po %d mėnesio.",
+        "Šis dokumentas nustos galioti po %d mėnesių.",
+        "Šis dokumentas nustos galioti po %d mėnesių.",
+        "Šis dokumentas nustos galioti po %d mėnesio."
+    ],
+    "Please enter the password for this paste:": "Įveskite šio įdėjimo slaptažodį:",
+    "Could not decrypt data (Wrong key?)": "Nepavyko iššifruoti duomenų (Neteisingas raktas?)",
+    "Could not delete the paste, it was not stored in burn after reading mode.": "Nepavyko ištrinti įdėjimo, jis nebuvo saugomas „Perskaičius sudeginti“ veiksenoje.",
+    "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "SKIRTA TIK JŪSŲ AKIMS. Neužverkite šio lango, šis pranešimas negalės būti rodomas dar kartą.",
+    "Could not decrypt comment; Wrong key?": "Nepavyko iššifruoti komentaro; Neteisingas raktas?",
+    "Reply": "Atsakyti",
+    "Anonymous": "Anonimas",
+    "Avatar generated from IP address": "Avataras sukurtas iš IP adreso",
+    "Add comment": "Pridėti komentarą",
+    "Optional nickname…": "Nebūtinas slapyvardis…",
+    "Post comment": "Skelbti komentarą",
+    "Sending comment…": "Siunčiamas komentaras…",
+    "Comment posted.": "Komentaras paskelbtas.",
+    "Could not refresh display: %s": "Nepavyko įkelti rodinio iš naujo: %s",
+    "unknown status": "nežinoma būsena",
+    "server error or not responding": "serverio klaida arba jis neatsako",
+    "Could not post comment: %s": "Nepavyko paskelbti komentaro: %s",
+    "Sending paste…": "Siunčiamas įdėjimas…",
+    "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>": "Jūsų įdėjimas yra <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Paspauskite [Vald]+[c] norėdami nukopijuoti)</span>",
+    "Delete data": "Ištrinti duomenis",
+    "Could not create paste: %s": "Nepavyko sukurti įdėjimo: %s",
+    "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Nepavyksta iššifruoti įdėjimo: URL adrese trūksta iššifravimo rakto (Ar naudojote peradresavimo ar URL trumpinimo įrankį, kuris pašalina URL dalį?)",
+    "B": "B",
+    "KiB": "KiB",
+    "MiB": "MiB",
+    "GiB": "GiB",
+    "TiB": "TiB",
+    "PiB": "PiB",
+    "EiB": "EiB",
+    "ZiB": "ZiB",
+    "YiB": "YiB",
+    "Format": "Formatas",
+    "Plain Text": "Grynasis tekstas",
+    "Source Code": "Pirminis kodas",
+    "Markdown": "„Markdown“",
+    "Download attachment": "Atsisiųsti priedą",
+    "Cloned: '%s'": "Dubliuota: „%s“",
+    "The cloned file '%s' was attached to this paste.": "Dubliuotas failas „%s“ buvo pridėtas į šį įdėjimą.",
+    "Attach a file": "Pridėti failą",
+    "alternatively drag & drop a file or paste an image from the clipboard": "arba kitaip - tempkite failą arba įdėkite paveikslą iš iškarpinės",
+    "File too large, to display a preview. Please download the attachment.": "Failas per didelis, kad būtų rodoma peržiūra. Atsisiųskite priedą.",
+    "Remove attachment": "Šalinti priedą",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "Jūsų naršyklė nepalaiko šifruotų failų įkėlimo. Naudokite naujesnę naršyklę.",
+    "Invalid attachment.": "Neteisingas priedas.",
+    "Options": "Parinktys",
+    "Shorten URL": "Sutrumpinti URL",
+    "Editor": "Redaktorius",
+    "Preview": "Peržiūra",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s reikalauja, kad PATH baigtųsi „%s“. Atnaujinkite PATH savo index.php.",
+    "Decrypt": "Iššifruoti",
+    "Enter password": "Įveskite slaptažodį",
+    "Loading…": "Įkeliama…",
+    "Decrypting paste…": "Iššifruojamas įdėjimas…",
+    "Preparing new paste…": "Ruošiamas naujas įdėjimas…",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "Jeigu šis pranešimas niekada nedingsta, pasižiūrėkite <a href=\"%s\">šį DUK skyrių, kuriame yra informacija apie nesklandumų šalinimą</a>.",
+    "+++ no paste text +++": "+++ nėra įdėjimo teksto +++",
+    "Could not get paste data: %s": "Nepavyko gauti įdėjimo duomenų: %s",
+    "QR code": "QR kodas",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "Ši internetinė svetainė naudoja nesaugų HTTP ryšį! Naudokite ją tik bandymams.",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "Išsamesnei informacijai, <a href=\"%s\">žiūrėkite šį DUK įrašą</a>.",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "Jūsų naršyklei gali prireikti HTTPS ryšio, kad palaikytų „WebCrypto“ API. Pabandykite <a href=\"%s\">persijungti į HTTPS</a>.",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "Jūsų naršyklė nepalaiko „WebAssembly“, kuri naudojama zlib glaudinimui. Jūs galite kurti neglaudintus dokumentus, tačiau negalite skaityti glaudintų dokumentų.",
+    "waiting on user to provide a password": "laukiama, kol naudotojas pateiks slaptažodį",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "Nepavyko iššifruoti duomenų. Ar įvedėte teisingą slaptažodį? Bandykite iš naujo pasinaudodami mygtuku viršuje.",
+    "Retry": "Bandyti dar kartą",
+    "Showing raw text…": "Rodomas neapdorotas tekstas…",
+    "Notice:": "Pranešimas:",
+    "This link will expire after %s.": "Ši nuoroda nustos galioti po %s.",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "Ši nuoroda gali būti atverta tik vieną kartą, nenaudokite savo naršyklėje mygtuko „Grįžti“ ar „Įkelti iš naujo“.",
+    "Link:": "Nuoroda:",
+    "Recipient may become aware of your timezone, convert time to UTC?": "Gavėjas gali sužinoti jūsų laiko juostą, konvertuoti laiką į suderintąjį pasaulinį laiką (UTC)?",
+    "Use Current Timezone": "Naudoti esamą laiko juostą",
+    "Convert To UTC": "Konvertuoti į UTC",
+    "Close": "Užverti",
+    "Encrypted note on PrivateBin": "Šifruoti užrašai ties PrivateBin",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Norėdami matyti užrašus, aplankykite šį tinklalapį. Pasidalinus šiuo URL adresu su kitais žmonėmis, jiems taip pat bus leidžiama prieiga prie šių užrašų.",
+    "URL shortener may expose your decrypt key in URL.": "URL trumpinimo įrankis gali atskleisti URL adrese jūsų iššifravimo raktą.",
+    "Save paste": "Įrašyti įdėjimą"
+}

+ 5 - 2
i18n/nl.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is een minimalistische, open source online pastebin waarbij de server geen kennis heeft van de geplakte gegevens. Gegevens worden gecodeerd/gedecodeerd <i> in de browser </i> met behulp van 256 bits AES. Meer informatie is te vinden op de <a href=\"https://privatebin.info/\">projectpagina</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is een minimalistische, open source online pastebin waarbij de server geen kennis heeft van de geplakte gegevens. Gegevens worden gecodeerd/gedecodeerd %s in de browser %s met behulp van 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Meer informatie is te vinden op de <a href=\"https://privatebin.info/\">projectpagina</a>.",
     "Because ignorance is bliss": "Onwetendheid is een zegen",
     "en": "nl",
     "Paste does not exist, has expired or has been deleted.": "Geplakte tekst bestaat niet, is verlopen of verwijderd.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/no.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s er en minimalistisk, åpen kildekode, elektronisk tilgjengelig pastebin hvor serveren ikke har kunnskap om dataene som limes inn. Dataene krypteres/dekrypteres <i>i nettleseren</i> ved hjelp av 256 bits AES. Mer informasjon om prosjektet på  <a href=\"https://privatebin.info/\">prosjektsiden</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s er en minimalistisk, åpen kildekode, elektronisk tilgjengelig pastebin hvor serveren ikke har kunnskap om dataene som limes inn. Dataene krypteres/dekrypteres %si nettleseren%s ved hjelp av 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Mer informasjon om prosjektet på <a href=\"https://privatebin.info/\">prosjektsiden</a>.",
     "Because ignorance is bliss": "Fordi uvitenhet er lykke",
     "en": "no",
     "Paste does not exist, has expired or has been deleted.": "Innlegget eksisterer ikke, er utløpt eller har blitt slettet.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Konverter til UTC",
     "Close": "Steng",
     "Encrypted note on PrivateBin": "Kryptert notat på PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Besøk denne lenken for å se notatet. Hvis lenken deles med andre, vil de også kunne se notatet."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Besøk denne lenken for å se notatet. Hvis lenken deles med andre, vil de også kunne se notatet.",
+    "URL shortener may expose your decrypt key in URL.": "URL forkorter kan avsløre dekrypteringsnøkkelen.",
+    "Save paste": "Lagre utklipp"
 }

+ 6 - 3
i18n/oc.json

@@ -1,13 +1,14 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s es un 'pastebin' (o gestionari d’extrachs de tèxte e còdi font) minimalista e open source, dins lo qual lo servidor a pas cap de coneissença de las donadas mandadas. Las donadas son chifradas/deschifradas <i>dins lo navigator</i> per un chiframent AES 256 bits. Mai informacions sus <a href=\"https://privatebin.info/\">la pagina del projècte</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s es un 'pastebin' (o gestionari d’extrachs de tèxte e còdi font) minimalista e open source, dins lo qual lo servidor a pas cap de coneissença de las donadas mandadas. Las donadas son chifradas/deschifradas %sdins lo navigator%s per un chiframent AES 256 bits.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Mai informacions sus <a href=\"https://privatebin.info/\">la pagina del projècte</a>.",
     "Because ignorance is bliss": "Perque lo bonaür es l’ignorància",
     "en": "oc",
     "Paste does not exist, has expired or has been deleted.": "Lo tèxte existís pas, a expirat, o es estat suprimit.",
     "%s requires php %s or above to work. Sorry.": "O planhèm, %s necessita php %s o superior per foncionar.",
     "%s requires configuration section [%s] to be present in configuration file.": "%s fa besonh de la seccion de configuracion [%s] dins lo fichièr de configuracion per foncionar.",
     "Please wait %d seconds between each post.": [
-        "Mercés d'esperar %d segonde entre cada publicacion.",
+        "Mercés d'esperar %d segonda entre cada publicacion.",
         "Mercés d'esperar %d segondas entre cada publicacion.",
         "Mercés d'esperar %d segondas entre cada publicacion.",
         "Mercés d'esperar %d segondas entre cada publicacion."
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convertir en UTC",
     "Close": "Tampar",
     "Encrypted note on PrivateBin": "Nòtas chifradas sus PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visitatz aqueste ligam per veire la nòta. Fornir lo ligam a qualqu’un mai li permet tanben d’accedir a la nòta."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visitatz aqueste ligam per veire la nòta. Fornir lo ligam a qualqu’un mai li permet tanben d’accedir a la nòta.",
+    "URL shortener may expose your decrypt key in URL.": "Los espleches d’acorchiment d’URL pòdon expausar la clau de deschiframent dins l’URL.",
+    "Save paste": "Enregistrar lo tèxt"
 }

+ 5 - 2
i18n/pl.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s jest minimalistycznym, otwartoźródłowym serwisem typu pastebin, w którym serwer nie ma jakichkolwiek informacji o tym, co jest wklejane. Dane są szyfrowane i deszyfrowane <i>w przeglądarce</i> z użyciem 256-bitowego klucza AES. Więcej informacji na <a href=\"https://privatebin.info/\">stronie projektu</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s jest minimalistycznym, otwartoźródłowym serwisem typu pastebin, w którym serwer nie ma jakichkolwiek informacji o tym, co jest wklejane. Dane są szyfrowane i deszyfrowane %sw przeglądarce%s z użyciem 256-bitowego klucza AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Więcej informacji na <a href=\"https://privatebin.info/\">stronie projektu</a>.",
     "Because ignorance is bliss": "Ponieważ ignorancja jest cnotą",
     "en": "pl",
     "Paste does not exist, has expired or has been deleted.": "Wklejka nie istnieje, wygasła albo została usunięta.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/pt.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s é um serviço minimalista e de código aberto do tipo \"pastebin\", em que o servidor tem zero conhecimento dos dados copiados. Os dados são cifrados e decifrados <i>no navegador</i> usando 256 bits AES. Mais informações na <a href=\"https://privatebin.info/\">página do projeto</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s é um serviço minimalista e de código aberto do tipo \"pastebin\", em que o servidor tem zero conhecimento dos dados copiados. Os dados são cifrados e decifrados %sno navegador%s usando 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Mais informações na <a href=\"https://privatebin.info/\">página do projeto</a>.",
     "Because ignorance is bliss": "Porque a ignorância é uma benção",
     "en": "pt",
     "Paste does not exist, has expired or has been deleted.": "A cópia não existe, expirou ou já foi excluída.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Converter para UTC",
     "Close": "Fechar",
     "Encrypted note on PrivateBin": "Nota criptografada no PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visite esse link para ver a nota. Dar a URL para qualquer um permite que eles também acessem a nota."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visite esse link para ver a nota. Dar a URL para qualquer um permite que eles também acessem a nota.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 6 - 3
i18n/ru.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s это минималистичный Open Source проект для создания заметок, где сервер не знает ничего о сохраняемых данных. Данные шифруются/расшифровываются <i>в браузере</i> с использованием 256 битного шифрования AES. Подробнее можно узнать на <a href=\"https://privatebin.info/\">сайте проекта</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s это минималистичный Open Source проект для создания заметок, где сервер не знает ничего о сохраняемых данных. Данные шифруются/расшифровываются %sв браузере%s с использованием 256 битного шифрования AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Подробнее можно узнать на <a href=\"https://privatebin.info/\">сайте проекта</a>.",
     "Because ignorance is bliss": "Потому что неведение - благо",
     "en": "ru",
     "Paste does not exist, has expired or has been deleted.": "Запись не существует, просрочена или была удалена.",
@@ -76,7 +77,7 @@
         "%d лет"
     ],
     "Never": "Никогда",
-    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Примечание: Этот сервис тестовый: Данные могут быть удалены в любое время. Котята умрут, если вы будете злоупотреблять серсисом.",
+    "Note: This is a test service: Data may be deleted anytime. Kittens will die if you abuse this service.": "Примечание: Этот сервис тестовый: Данные могут быть удалены в любое время. Котята умрут, если вы будете злоупотреблять сервисом.",
     "This document will expire in %d seconds.": [
         "Документ будет удален через %d секунду.",
         "Документ будет удален через %d секунды.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Конвертировать в UTC",
     "Close": "Закрыть",
     "Encrypted note on PrivateBin": "Зашифрованная запись на PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Посетите эту ссылку чтобы просмотреть запись. Передача ссылки кому либо позволит им получить доступ к записи тоже."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Посетите эту ссылку чтобы просмотреть запись. Передача ссылки кому либо позволит им получить доступ к записи тоже.",
+    "URL shortener may expose your decrypt key in URL.": "Сервис сокращения ссылок может получить ваш ключ расшифровки из ссылки.",
+    "Save paste": "Сохранить запись"
 }

+ 5 - 2
i18n/sl.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s je minimalističen, odprtokodni spletni 'pastebin', kjer server ne ve ničesar o prilepljenih podatkih. Podatki so zakodirani/odkodirani <i>v brskalniku</i> z uporabo 256 bitnega AES. Več informacij na <a href=\"https://privatebin.info/\">spletni strani projekta.</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s je minimalističen, odprtokodni spletni 'pastebin', kjer server ne ve ničesar o prilepljenih podatkih. Podatki so zakodirani/odkodirani %sv brskalniku%s z uporabo 256 bitnega AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Več informacij na <a href=\"https://privatebin.info/\">spletni strani projekta.</a>.",
     "Because ignorance is bliss": "Ker kar ne veš ne boli.",
     "en": "sl",
     "Paste does not exist, has expired or has been deleted.": "Prilepek ne obstaja, mu je potekla življenjska doba, ali pa je izbrisan.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/sv.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "More information on the <a href=\"https://privatebin.info/\">project page</a>.",
     "Because ignorance is bliss": "Because ignorance is bliss",
     "en": "sv",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 31 - 28
i18n/tr.json

@@ -1,7 +1,8 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.",
-    "Because ignorance is bliss": "Because ignorance is bliss",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Daha fazla bilgi için <a href=\"https://privatebin.info/\">proje sayfası</a>'na göz atabilirsiniz.",
+    "Because ignorance is bliss": "Çünkü, cehalet mutluluktur",
     "en": "tr",
     "Paste does not exist, has expired or has been deleted.": "Paste does not exist, has expired or has been deleted.",
     "%s requires php %s or above to work. Sorry.": "%s requires php %s or above to work. Sorry.",
@@ -13,8 +14,8 @@
         "Please wait %d seconds between each post. (3rd plural)"
     ],
     "Paste is limited to %s of encrypted data.": "Paste is limited to %s of encrypted data.",
-    "Invalid data.": "Invalid data.",
-    "You are unlucky. Try again.": "You are unlucky. Try again.",
+    "Invalid data.": "Geçersiz veri.",
+    "You are unlucky. Try again.": "Lütfen tekrar deneyiniz.",
     "Error saving comment. Sorry.": "Error saving comment. Sorry.",
     "Error saving paste. Sorry.": "Error saving paste. Sorry.",
     "Invalid paste ID.": "Invalid paste ID.",
@@ -23,16 +24,16 @@
     "Paste was properly deleted.": "Paste was properly deleted.",
     "JavaScript is required for %s to work. Sorry for the inconvenience.": "JavaScript is required for %s to work. Sorry for the inconvenience.",
     "%s requires a modern browser to work.": "%s requires a modern browser to work.",
-    "New": "New",
-    "Send": "Send",
-    "Clone": "Clone",
+    "New": "Yeni",
+    "Send": "Gönder",
+    "Clone": "Kopyala",
     "Raw text": "Raw text",
-    "Expires": "Expires",
+    "Expires": "Süre Sonu",
     "Burn after reading": "Burn after reading",
-    "Open discussion": "Open discussion",
+    "Open discussion": "Açık Tartışmalar",
     "Password (recommended)": "Password (recommended)",
-    "Discussion": "Discussion",
-    "Toggle navigation": "Toggle navigation",
+    "Discussion": "Tartışma",
+    "Toggle navigation": "Gezinmeyi değiştir",
     "%d seconds": [
         "%d second (singular)",
         "%d seconds (1st plural)",
@@ -58,8 +59,8 @@
         "%d days (3rd plural)"
     ],
     "%d weeks": [
-        "%d week (singular)",
-        "%d weeks (1st plural)",
+        "%d hafta (tekil)",
+        "%d haftalar (çoğul)",
         "%d weeks (2nd plural)",
         "%d weeks (3rd plural)"
     ],
@@ -112,21 +113,21 @@
     "Could not delete the paste, it was not stored in burn after reading mode.": "Could not delete the paste, it was not stored in burn after reading mode.",
     "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.",
     "Could not decrypt comment; Wrong key?": "Could not decrypt comment; Wrong key?",
-    "Reply": "Reply",
-    "Anonymous": "Anonymous",
+    "Reply": "Cevapla",
+    "Anonymous": "Anonim",
     "Avatar generated from IP address": "Avatar generated from IP address",
-    "Add comment": "Add comment",
+    "Add comment": "Yorum ekle",
     "Optional nickname…": "Optional nickname…",
-    "Post comment": "Post comment",
+    "Post comment": "Yorumu gönder",
     "Sending comment…": "Sending comment…",
-    "Comment posted.": "Comment posted.",
+    "Comment posted.": "Yorum gönderildi.",
     "Could not refresh display: %s": "Could not refresh display: %s",
     "unknown status": "unknown status",
     "server error or not responding": "server error or not responding",
     "Could not post comment: %s": "Could not post comment: %s",
     "Sending paste…": "Sending paste…",
     "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>": "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>",
-    "Delete data": "Delete data",
+    "Delete data": "Veriyi sil",
     "Could not create paste: %s": "Could not create paste: %s",
     "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)",
     "B": "B",
@@ -154,33 +155,35 @@
     "Options": "Options",
     "Shorten URL": "Shorten URL",
     "Editor": "Editor",
-    "Preview": "Preview",
+    "Preview": "Ön izleme",
     "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.",
     "Decrypt": "Decrypt",
-    "Enter password": "Enter password",
-    "Loading…": "Loading…",
+    "Enter password": "Şifreyi girin",
+    "Loading…": "Yükleniyor…",
     "Decrypting paste…": "Decrypting paste…",
     "Preparing new paste…": "Preparing new paste…",
     "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.",
     "+++ no paste text +++": "+++ no paste text +++",
     "Could not get paste data: %s": "Could not get paste data: %s",
-    "QR code": "QR code",
+    "QR code": "QR kodu",
     "This website is using an insecure HTTP connection! Please use it only for testing.": "This website is using an insecure HTTP connection! Please use it only for testing.",
     "For more information <a href=\"%s\">see this FAQ entry</a>.": "For more information <a href=\"%s\">see this FAQ entry</a>.",
     "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.",
     "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.",
     "waiting on user to provide a password": "waiting on user to provide a password",
     "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.",
-    "Retry": "Retry",
+    "Retry": "Yeniden Dene",
     "Showing raw text…": "Showing raw text…",
-    "Notice:": "Notice:",
+    "Notice:": "Bildirim:",
     "This link will expire after %s.": "This link will expire after %s.",
     "This link can only be accessed once, do not use back or refresh button in your browser.": "This link can only be accessed once, do not use back or refresh button in your browser.",
-    "Link:": "Link:",
+    "Link:": "Bağlantı:",
     "Recipient may become aware of your timezone, convert time to UTC?": "Recipient may become aware of your timezone, convert time to UTC?",
     "Use Current Timezone": "Use Current Timezone",
     "Convert To UTC": "Convert To UTC",
-    "Close": "Close",
+    "Close": "Kapat",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 5 - 2
i18n/uk.json

@@ -1,6 +1,7 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s це мінімалістичний Open Source проєкт для створення нотаток, де сервер не знає нічого про дані, що зберігаються. Дані шифруються/розшифровуються <i>у переглядачі</i> з використанням 256-бітного шифрувания AES. Подробиці можна дізнатися на <a href=\"https://privatebin.info/\">сайті проєкту</a>.",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s це мінімалістичний Open Source проєкт для створення нотаток, де сервер не знає нічого про дані, що зберігаються. Дані шифруються/розшифровуються %sу переглядачі%s з використанням 256-бітного шифрувания AES.",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "Подробиці можна дізнатися на <a href=\"https://privatebin.info/\">сайті проєкту</a>.",
     "Because ignorance is bliss": "Бо незнання - благо",
     "en": "uk",
     "Paste does not exist, has expired or has been deleted.": "Допис не існує, протермінований чи був видалений.",
@@ -182,5 +183,7 @@
     "Convert To UTC": "Convert To UTC",
     "Close": "Close",
     "Encrypted note on PrivateBin": "Encrypted note on PrivateBin",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too."
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.",
+    "URL shortener may expose your decrypt key in URL.": "URL shortener may expose your decrypt key in URL.",
+    "Save paste": "Save paste"
 }

+ 32 - 29
i18n/zh.json

@@ -1,28 +1,29 @@
 {
     "PrivateBin": "PrivateBin",
-    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href=\"https://privatebin.info/\">project page</a>.": "%s是一个极简、开源、对粘贴内容毫不知情的在线粘贴板,数据<i>在浏览器内</i>进行AES-256加密。更多信息请查看<a href=\"https://privatebin.info/\">项目主页</a>。",
+    "%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.": "%s 是一个极简、开源、对粘贴内容毫不知情的在线粘贴板,数据%s在浏览器内%s进行 AES-256 加密和解密。",
+    "More information on the <a href=\"https://privatebin.info/\">project page</a>.": "更多信息请查看<a href=\"https://privatebin.info/\">项目主页</a>。",
     "Because ignorance is bliss": "因为无知是福",
     "en": "zh",
-    "Paste does not exist, has expired or has been deleted.": "粘贴内容不存在已过期或已被删除。",
-    "%s requires php %s or above to work. Sorry.": "%s需要PHP %s及以上版本来工作,抱歉。",
-    "%s requires configuration section [%s] to be present in configuration file.": "%s需要设置配置文件中 [%s] 部分。",
+    "Paste does not exist, has expired or has been deleted.": "粘贴内容不存在已过期或已被删除。",
+    "%s requires php %s or above to work. Sorry.": "抱歉,%s 需要 PHP %s 及以上版本才能运行。",
+    "%s requires configuration section [%s] to be present in configuration file.": "%s 需要设置配置文件中 [%s] 部分。",
     "Please wait %d seconds between each post.": [
         "每 %d 秒只能粘贴一次。",
         "每 %d 秒只能粘贴一次。",
         "每 %d 秒只能粘贴一次。",
         "每 %d 秒只能粘贴一次。"
     ],
-    "Paste is limited to %s of encrypted data.": "粘贴受限于 %s 加密数据。",
+    "Paste is limited to %s of encrypted data.": "对于加密数据,上限为 %s。",
     "Invalid data.": "无效的数据。",
     "You are unlucky. Try again.": "请再试一次。",
     "Error saving comment. Sorry.": "保存评论时出现错误,抱歉。",
     "Error saving paste. Sorry.": "保存粘贴内容时出现错误,抱歉。",
-    "Invalid paste ID.": "无效的ID。",
+    "Invalid paste ID.": "无效的 ID。",
     "Paste is not of burn-after-reading type.": "粘贴内容不是阅后即焚类型。",
     "Wrong deletion token. Paste was not deleted.": "错误的删除token,粘贴内容没有被删除。",
     "Paste was properly deleted.": "粘贴内容已被正确删除。",
-    "JavaScript is required for %s to work. Sorry for the inconvenience.": "%s需要JavaScript来进行加解密。 给你带来的不便敬请谅解。",
-    "%s requires a modern browser to work.": "%s需要在现代浏览器上工作。",
+    "JavaScript is required for %s to work. Sorry for the inconvenience.": "%s 需要 JavaScript 来进行加解密。 给你带来的不便敬请谅解。",
+    "%s requires a modern browser to work.": "%s 需要在现代浏览器上工作。",
     "New": "新建",
     "Send": "送出",
     "Clone": "复制",
@@ -111,7 +112,7 @@
     "Could not decrypt data (Wrong key?)": "无法解密数据(密钥错误?)",
     "Could not delete the paste, it was not stored in burn after reading mode.": "无法删除此粘贴内容,它没有以阅后即焚模式保存。",
     "FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.": "看!仔!细!了!不要关闭窗口,否则你再也见不到这条消息了。",
-    "Could not decrypt comment; Wrong key?": "无法解密评论; 密钥错误?",
+    "Could not decrypt comment; Wrong key?": "无法解密评论密钥错误?",
     "Reply": "回复",
     "Anonymous": "匿名",
     "Avatar generated from IP address": "由IP生成的头像",
@@ -125,7 +126,7 @@
     "server error or not responding": "服务器错误或无回应",
     "Could not post comment: %s": "无法发送评论: %s",
     "Sending paste…": "粘贴内容提交中…",
-    "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>": "您粘贴内容的链接是<a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按下 [Ctrl]+[c] 以复制)</span>",
+    "Your paste is <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(Hit [Ctrl]+[c] to copy)</span>": "您粘贴内容的链接是 <a id=\"pasteurl\" href=\"%s\">%s</a> <span id=\"copyhint\">(按下 [Ctrl]+[C] 以复制)</span>",
     "Delete data": "删除数据",
     "Could not create paste: %s": "无法创建粘贴:%s",
     "Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)": "无法解密粘贴:URL中缺失解密密钥(是否使用了重定向或者短链接导致密钥丢失?)",
@@ -143,44 +144,46 @@
     "Source Code": "源代码",
     "Markdown": "Markdown",
     "Download attachment": "下载附件",
-    "Cloned: '%s'": "副本: '%s'",
-    "The cloned file '%s' was attached to this paste.": "副本 '%s' 已附加到此粘贴内容。",
+    "Cloned: '%s'": "副本:“%s”",
+    "The cloned file '%s' was attached to this paste.": "副本“%s”已附加到此粘贴内容。",
     "Attach a file": "添加一个附件",
     "alternatively drag & drop a file or paste an image from the clipboard": "拖放文件或从剪贴板粘贴图片",
-    "File too large, to display a preview. Please download the attachment.": "文件过大。要显示预览,请下载附件。",
+    "File too large, to display a preview. Please download the attachment.": "文件过大,无法显示预览。请下载附件。",
     "Remove attachment": "移除附件",
-    "Your browser does not support uploading encrypted files. Please use a newer browser.": "您的浏览器不支持上传加密的文件,请使用新的浏览器。",
+    "Your browser does not support uploading encrypted files. Please use a newer browser.": "您的浏览器不支持上传加密的文件,请使用新版本的浏览器。",
     "Invalid attachment.": "无效的附件",
     "Options": "选项",
     "Shorten URL": "缩短链接",
     "Editor": "编辑",
     "Preview": "预览",
-    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s 的 PATH 变量必须结束于 \"%s\"。 请修改你的 index.php 中的 PATH 变量。",
+    "%s requires the PATH to end in a \"%s\". Please update the PATH in your index.php.": "%s 的 PATH 变量必须结束于“%s”。 请修改你的 index.php 中的 PATH 变量。",
     "Decrypt": "解密",
     "Enter password": "输入密码",
     "Loading…": "载入中…",
     "Decrypting paste…": "正在解密",
     "Preparing new paste…": "正在准备新的粘贴内容",
-    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "如果这个消息一直存在,请参考 <a href=\"%s\">这里的 FAQ (英文版)</a>进行故障排除。",
-    "+++ no paste text +++": "+++ 没有粘贴内容 +++",
+    "In case this message never disappears please have a look at <a href=\"%s\">this FAQ for information to troubleshoot</a>.": "如果消息一直存在,请参考 <a href=\"%s\">这里的 FAQ(英文版)</a>排除故障。",
+    "+++ no paste text +++": "+++ 粘贴内容 +++",
     "Could not get paste data: %s": "无法获取粘贴数据:%s",
     "QR code": "二维码",
-    "This website is using an insecure HTTP connection! Please use it only for testing.": "该网站使用了不安全的HTTP连接! 请仅将其用于测试。",
-    "For more information <a href=\"%s\">see this FAQ entry</a>.": "有关更多信息,<a href=\"%s\">请参阅此常见问题解答</a>。",
-    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "您的浏览器可能需要HTTPS连接才能支持WebCrypto API。 尝试<a href=\"%s\">切换到HTTPS </a>。",
-    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "您的浏览器不支持用于zlib压缩的WebAssembly。 您可以创建未压缩的文档,但不能读取压缩的文档。",
+    "This website is using an insecure HTTP connection! Please use it only for testing.": "该网站使用了不安全的 HTTP 连接!请仅将其用于测试。",
+    "For more information <a href=\"%s\">see this FAQ entry</a>.": "有关更多信息,请参阅<a href=\"%s\">此常见问题解答</a>。",
+    "Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href=\"%s\">switching to HTTPS</a>.": "您的浏览器可能需要 HTTPS 连接才能支持 WebCrypto API。 尝试<a href=\"%s\">切换到 HTTPS</a>。",
+    "Your browser doesn't support WebAssembly, used for zlib compression. You can create uncompressed documents, but can't read compressed ones.": "您的浏览器不支持用于 zlib 压缩的 WebAssembly。 您可以创建未压缩的文档,但不能读取压缩的文档。",
     "waiting on user to provide a password": "请输入密码",
-    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "无法解密数据。 您输入了错误的密码吗? 点顶部的按钮重试。",
+    "Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.": "无法解密数据。您是否输入了错误的密码?按顶部的按钮重试。",
     "Retry": "重试",
     "Showing raw text…": "显示原始文字…",
-    "Notice:": "注意:",
+    "Notice:": "注意",
     "This link will expire after %s.": "这个链接将会在 %s 过期。",
-    "This link can only be accessed once, do not use back or refresh button in your browser.": "这个链接只能被访问一次,请勿使用浏览器中的返回和刷新按钮。",
-    "Link:": "链接地址:",
-    "Recipient may become aware of your timezone, convert time to UTC?": "收件人可能会知道您的时区,将时间转换为UTC?",
+    "This link can only be accessed once, do not use back or refresh button in your browser.": "链接只能被访问一次,请勿使用浏览器中的返回和刷新按钮。",
+    "Link:": "链接",
+    "Recipient may become aware of your timezone, convert time to UTC?": "收件人可能会知道您的时区,将时间转换为 UTC?",
     "Use Current Timezone": "使用当前时区",
-    "Convert To UTC": "转换为UTC",
+    "Convert To UTC": "转换为 UTC",
     "Close": "关闭",
-    "Encrypted note on PrivateBin": "PrivateBin上的加密笔记",
-    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "访问这个链接来查看该笔记。 将这个URL发送给任何人即可允许其访问该笔记。"
+    "Encrypted note on PrivateBin": "PrivateBin 上的加密笔记",
+    "Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.": "访问此链接来查看该笔记。将此 URL 发送给任何人即可允许其访问该笔记。",
+    "URL shortener may expose your decrypt key in URL.": "短链接服务可能会暴露您在 URL 中的解密密钥。",
+    "Save paste": "保存内容"
 }

+ 1 - 1
index.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 // change this, if your php files and data is outside of your webservers document root

+ 1 - 1
js/common.js

@@ -17,7 +17,7 @@ require('./prettify');
 global.prettyPrint = window.PR.prettyPrint;
 global.prettyPrintOne = window.PR.prettyPrintOne;
 global.showdown = require('./showdown-1.9.1');
-global.DOMPurify = require('./purify-2.0.14');
+global.DOMPurify = require('./purify-2.2.7');
 global.baseX = require('./base-x-3.0.7').baseX;
 global.Legacy = require('./legacy').Legacy;
 require('./bootstrap-3.3.7');

Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
js/kjua-0.6.0.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
js/kjua-0.9.0.js


+ 1 - 1
js/package.json

@@ -1,6 +1,6 @@
 {
   "name": "privatebin",
-  "version": "1.3.0",
+  "version": "1.3.5",
   "description": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted in the browser using 256 bit AES in Galois Counter mode (GCM).",
   "main": "privatebin.js",
   "directories": {

+ 68 - 12
js/privatebin.js

@@ -6,7 +6,7 @@
  * @see       {@link https://github.com/PrivateBin/PrivateBin}
  * @copyright 2012 Sébastien SAUVAGE ({@link http://sebsauvage.net})
  * @license   {@link https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License}
- * @version   1.3.4
+ * @version   1.3.5
  * @name      PrivateBin
  * @namespace
  */
@@ -601,7 +601,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
          * @prop   {string[]}
          * @readonly
          */
-        const supportedLanguages = ['bg', 'cs', 'de', 'es', 'fr', 'it', 'hu', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sl', 'uk', 'zh'];
+        const supportedLanguages = ['bg', 'ca', 'cs', 'de', 'es', 'et', 'fr', 'he', 'hu', 'id', 'it', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sl', 'uk', 'zh'];
 
         /**
          * built in language
@@ -767,7 +767,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
         /**
          * per language functions to use to determine the plural form
          *
-         * @see    {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html}
+         * @see    {@link https://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html}
          * @name   I18n.getPluralForm
          * @function
          * @param  {int} n
@@ -782,6 +782,12 @@ jQuery.PrivateBin = (function($, RawDeflate) {
                 case 'oc':
                 case 'zh':
                     return n > 1 ? 1 : 0;
+                case 'he':
+                    return n === 1 ? 0 : (n === 2 ? 1 : ((n < 0 || n > 10) && (n % 10 === 0) ? 2 : 3));
+                case 'id':
+                    return 0;
+                case 'lt':
+                    return n % 10 === 1 && n % 100 !== 11 ? 0 : ((n % 10 >= 2 && n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
                 case 'pl':
                     return n === 1 ? 0 : (n % 10 >= 2 && n %10 <=4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
                 case 'ru':
@@ -789,7 +795,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
                     return n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
                 case 'sl':
                     return n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0));
-                // bg, de, en, es, hu, it, nl, no, pt
+                // bg, ca, de, en, es, et, hu, it, nl, no, pt
                 default:
                     return n !== 1 ? 1 : 0;
             }
@@ -3502,6 +3508,8 @@ jQuery.PrivateBin = (function($, RawDeflate) {
 
         let createButtonsDisplayed = false,
             viewButtonsDisplayed = false,
+            burnAfterReadingDefault = false,
+            openDiscussionDefault = false,
             $attach,
             $burnAfterReading,
             $burnAfterReadingOption,
@@ -3517,6 +3525,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             $password,
             $passwordInput,
             $rawTextButton,
+            $downloadTextButton,
             $qrCodeLink,
             $emailLink,
             $sendButton,
@@ -3658,6 +3667,30 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             newDoc.close();
         }
 
+        /**
+         * download text
+         *
+         * @name   TopNav.downloadText
+         * @private
+         * @function
+         */
+        function downloadText()
+        {
+            var filename='paste-' + Model.getPasteId() + '.txt';
+            var text = PasteViewer.getText();
+
+            var element = document.createElement('a');
+            element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
+            element.setAttribute('download', filename);
+
+            element.style.display = 'none';
+            document.body.appendChild(element);
+
+            element.click();
+
+            document.body.removeChild(element);
+        }
+
         /**
          * saves the language in a cookie and reloads the page
          *
@@ -3668,7 +3701,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
          */
         function setLanguage(event)
         {
-            document.cookie = 'lang=' + $(event.target).data('lang');
+            document.cookie = 'lang=' + $(event.target).data('lang') + ';secure';
             UiHelper.reloadHome();
         }
 
@@ -3884,6 +3917,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             $newButton.removeClass('hidden');
             $cloneButton.removeClass('hidden');
             $rawTextButton.removeClass('hidden');
+            $downloadTextButton.removeClass('hidden');
             $qrCodeLink.removeClass('hidden');
 
             viewButtonsDisplayed = true;
@@ -3904,6 +3938,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             $cloneButton.addClass('hidden');
             $newButton.addClass('hidden');
             $rawTextButton.addClass('hidden');
+            $downloadTextButton.addClass('hidden');
             $qrCodeLink.addClass('hidden');
             me.hideEmailButton();
 
@@ -4065,6 +4100,17 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             $rawTextButton.addClass('hidden');
         };
 
+        /**
+         * only hides the download text button
+         *
+         * @name   TopNav.hideRawButton
+         * @function
+         */
+        me.hideDownloadButton = function()
+        {
+            $downloadTextButton.addClass('hidden');
+        };
+
         /**
          * only hides the qr code button
          *
@@ -4146,13 +4192,18 @@ jQuery.PrivateBin = (function($, RawDeflate) {
         me.resetInput = function()
         {
             clearAttachmentInput();
+            $burnAfterReading.prop('checked', burnAfterReadingDefault);
+            $openDiscussion.prop('checked', openDiscussionDefault);
+            if (openDiscussionDefault || !burnAfterReadingDefault) $openDiscussionOption.removeClass('buttondisabled');
+            if (burnAfterReadingDefault || !openDiscussionDefault) $burnAfterReadingOption.removeClass('buttondisabled');
 
-            $openDiscussion.prop('checked', false);
-            $burnAfterReading.prop('checked', false);
-            $openDiscussionOption.removeClass('buttondisabled');
-            $burnAfterReadingOption.removeClass('buttondisabled');
-
-            // TODO: reset expiration time
+            pasteExpiration = Model.getExpirationDefault() || pasteExpiration;
+            $('#pasteExpiration>option').each(function() {
+                const $this = $(this);
+                if ($this.val() === pasteExpiration) {
+                    $('#pasteExpirationDisplay').text($this.text());
+                }
+            });
         };
 
         /**
@@ -4321,6 +4372,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             $password = $('#password');
             $passwordInput = $('#passwordinput');
             $rawTextButton = $('#rawtextbutton');
+            $downloadTextButton = $('#downloadtextbutton');
             $retryButton = $('#retrybutton');
             $sendButton = $('#sendbutton');
             $qrCodeLink = $('#qrcodelink');
@@ -4338,6 +4390,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             $sendButton.click(PasteEncrypter.sendPaste);
             $cloneButton.click(Controller.clonePaste);
             $rawTextButton.click(rawText);
+            $downloadTextButton.click(downloadText);
             $retryButton.click(clickRetryButton);
             $fileRemoveButton.click(removeAttachment);
             $qrCodeLink.click(displayQrCode);
@@ -4350,7 +4403,9 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             changeBurnAfterReading();
             changeOpenDiscussion();
 
-            // get default value from template or fall back to set value
+            // get default values from template or fall back to set value
+            burnAfterReadingDefault = me.getBurnAfterReading();
+            openDiscussionDefault = me.getOpenDiscussion();
             pasteExpiration = Model.getExpirationDefault() || pasteExpiration;
 
             createButtonsDisplayed = false;
@@ -4674,6 +4729,7 @@ jQuery.PrivateBin = (function($, RawDeflate) {
             TopNav.showEmailButton();
 
             TopNav.hideRawButton();
+            TopNav.hideDownloadButton();
             Editor.hide();
 
             // parse and show text

Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
js/purify-2.0.14.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
js/purify-2.2.7.js


+ 125 - 0
js/test/TopNav.js

@@ -317,6 +317,131 @@ describe('TopNav', function () {
         );
     });
 
+    describe('resetInput', function () {
+        before(function () {
+            cleanup();
+        });
+
+        it(
+            'reset inputs to defaults (options off)',
+            function () {
+                var results = [];
+                $('body').html(
+                    '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
+                    'class="hidden"><label><input type="checkbox" ' +
+                    'id="burnafterreading" name="burnafterreading" /> ' +
+                    'Burn after reading</label></li><li id="opendiscussionoption" ' +
+                    'class="hidden"><label><input type="checkbox" ' +
+                    'id="opendiscussion" name="opendiscussion" /> ' +
+                    'Open discussion</label></li></ul></div></nav>'
+                );
+                $.PrivateBin.TopNav.init();
+                results.push(
+                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                $('#burnafterreading').attr('checked', 'checked');
+                $('#opendiscussion').attr('checked', 'checked');
+                results.push(
+                    $.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    $.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                $.PrivateBin.TopNav.resetInput();
+                results.push(
+                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                cleanup();
+                assert.ok(results.every(element => element));
+            }
+        );
+
+        it(
+            'reset inputs to defaults (burnafterreading on)',
+            function () {
+                var results = [];
+                $('body').html(
+                    '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
+                    'class="hidden"><label><input type="checkbox" ' +
+                    'id="burnafterreading" name="burnafterreading" checked="checked" /> ' +
+                    'Burn after reading</label></li><li id="opendiscussionoption" ' +
+                    'class="hidden"><label><input type="checkbox" ' +
+                    'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
+                    'Open discussion</label></li></ul></div></nav>'
+                );
+                $.PrivateBin.TopNav.init();
+                results.push(
+                    $.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                $('#burnafterreading').removeAttr('checked');
+                results.push(
+                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                $.PrivateBin.TopNav.resetInput();
+                results.push(
+                    $.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                cleanup();
+                assert.ok(results.every(element => element));
+            }
+        );
+
+        it(
+            'reset inputs to defaults (opendiscussion on)',
+            function () {
+                var results = [];
+                $('body').html(
+                    '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
+                    'class="hidden"><label><input type="checkbox" ' +
+                    'id="burnafterreading" name="burnafterreading" /> ' +
+                    'Burn after reading</label></li><li id="opendiscussionoption" ' +
+                    'class="hidden"><label><input type="checkbox" ' +
+                    'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
+                    'Open discussion</label></li></ul></div></nav>'
+                );
+                $.PrivateBin.TopNav.init();
+                results.push(
+                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    $.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                $('#opendiscussion').removeAttr('checked');
+                $('#burnafterreading').prop('checked', true);
+                results.push(
+                    $.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                $.PrivateBin.TopNav.resetInput();
+                results.push(
+                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                );
+                results.push(
+                    $.PrivateBin.TopNav.getOpenDiscussion()
+                );
+                cleanup();
+                assert.ok(results.every(element => element));
+            }
+        );
+    });
+
     describe('getExpiration', function () {
         before(function () {
             cleanup();

+ 30 - 26
lib/Configuration.php

@@ -7,14 +7,13 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;
 
 use Exception;
 use PDO;
-use PrivateBin\Persistence\DataStore;
 
 /**
  * Configuration
@@ -48,13 +47,14 @@ class Configuration
             'syntaxhighlightingtheme'  => null,
             'sizelimit'                => 10485760,
             'template'                 => 'bootstrap',
+            'info'                     => 'More information on the <a href=\'https://privatebin.info/\'>project page</a>.',
             'notice'                   => '',
             'languageselection'        => false,
             'languagedefault'          => '',
             'urlshortener'             => '',
             'qrcode'                   => true,
             'icon'                     => 'identicon',
-            'cspheader'                => 'default-src \'none\'; manifest-src \'self\'; connect-src * blob:; script-src \'self\' \'unsafe-eval\' resource:; style-src \'self\'; font-src \'self\'; img-src \'self\' data: blob:; media-src blob:; object-src blob:; sandbox allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads',
+            'cspheader'                => 'default-src \'none\'; base-uri \'self\'; form-action \'none\'; manifest-src \'self\'; connect-src * blob:; script-src \'self\' \'unsafe-eval\' resource:; style-src \'self\'; font-src \'self\'; img-src \'self\' data: blob:; media-src blob:; object-src blob:; sandbox allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads',
             'zerobincompatibility'     => false,
             'httpwarning'              => true,
             'compression'              => 'zlib',
@@ -78,14 +78,13 @@ class Configuration
             'markdown'           => 'Markdown',
         ),
         'traffic' => array(
-            'limit'  => 10,
-            'header' => null,
-            'dir'    => 'data',
+            'limit'      => 10,
+            'header'     => null,
+            'exemptedIp' => null,
         ),
         'purge' => array(
             'limit'     => 300,
             'batchsize' => 10,
-            'dir'       => 'data',
         ),
         'model' => array(
             'class' => 'Filesystem',
@@ -102,28 +101,23 @@ class Configuration
      */
     public function __construct()
     {
+        $basePaths  = array();
         $config     = array();
-        $basePath   = (getenv('CONFIG_PATH') !== false ? getenv('CONFIG_PATH') : PATH . 'cfg') . DIRECTORY_SEPARATOR;
-        $configIni  = $basePath . 'conf.ini';
-        $configFile = $basePath . 'conf.php';
-
-        // rename INI files to avoid configuration leakage
-        if (is_readable($configIni)) {
-            DataStore::prependRename($configIni, $configFile, ';');
-
-            // cleanup sample, too
-            $configIniSample = $configIni . '.sample';
-            if (is_readable($configIniSample)) {
-                DataStore::prependRename($configIniSample, $basePath . 'conf.sample.php', ';');
-            }
+        $configPath = getenv('CONFIG_PATH');
+        if ($configPath !== false && !empty($configPath)) {
+            $basePaths[] = $configPath;
         }
-
-        if (is_readable($configFile)) {
-            $config = parse_ini_file($configFile, true);
-            foreach (array('main', 'model', 'model_options') as $section) {
-                if (!array_key_exists($section, $config)) {
-                    throw new Exception(I18n::_('PrivateBin requires configuration section [%s] to be present in configuration file.', $section), 2);
+        $basePaths[] = PATH . 'cfg';
+        foreach ($basePaths as $basePath) {
+            $configFile = $basePath . DIRECTORY_SEPARATOR . 'conf.php';
+            if (is_readable($configFile)) {
+                $config = parse_ini_file($configFile, true);
+                foreach (array('main', 'model', 'model_options') as $section) {
+                    if (!array_key_exists($section, $config)) {
+                        throw new Exception(I18n::_('PrivateBin requires configuration section [%s] to be present in configuration file.', $section), 2);
+                    }
                 }
+                break;
             }
         }
 
@@ -151,6 +145,16 @@ class Configuration
                     'pwd' => null,
                     'opt' => array(PDO::ATTR_PERSISTENT => true),
                 );
+            } elseif (
+                $section == 'model_options' && in_array(
+                    $this->_configuration['model']['class'],
+                    array('GoogleCloudStorage')
+                )
+            ) {
+                $values = array(
+                    'bucket' => getenv('PRIVATEBIN_GCS_BUCKET') ? getenv('PRIVATEBIN_GCS_BUCKET') : null,
+                    'prefix' => 'pastes',
+                );
             }
 
             // "*_options" sections don't require all defaults to be set

+ 22 - 21
lib/Controller.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;
@@ -28,7 +28,7 @@ class Controller
      *
      * @const string
      */
-    const VERSION = '1.3.4';
+    const VERSION = '1.3.5';
 
     /**
      * minimal required PHP version
@@ -162,7 +162,6 @@ class Controller
         $this->_model   = new Model($this->_conf);
         $this->_request = new Request;
         $this->_urlBase = $this->_request->getRequestUri();
-        ServerSalt::setPath($this->_conf->getKey('dir', 'traffic'));
 
         // set default language
         $lang = $this->_conf->getKey('languagedefault');
@@ -170,7 +169,7 @@ class Controller
         // force default language, if language selection is disabled and a default is set
         if (!$this->_conf->getKey('languageselection') && strlen($lang) == 2) {
             $_COOKIE['lang'] = $lang;
-            setcookie('lang', $lang);
+            setcookie('lang', $lang, 0, '', '', true);
         }
     }
 
@@ -196,20 +195,17 @@ class Controller
      */
     private function _create()
     {
-        try {
-            // Ensure last paste from visitors IP address was more than configured amount of seconds ago.
-            TrafficLimiter::setConfiguration($this->_conf);
-            if (!TrafficLimiter::canPass()) {
-                $this->_return_message(
-                    1, I18n::_(
-                        'Please wait %d seconds between each post.',
-                        $this->_conf->getKey('limit', 'traffic')
-                    )
-                );
-                return;
-            }
-        } catch (Exception $e) {
-            $this->_return_message(1, I18n::_($e->getMessage()));
+        // Ensure last paste from visitors IP address was more than configured amount of seconds ago.
+        ServerSalt::setStore($this->_model->getStore());
+        TrafficLimiter::setConfiguration($this->_conf);
+        TrafficLimiter::setStore($this->_model->getStore());
+        if (!TrafficLimiter::canPass()) {
+            $this->_return_message(
+                1, I18n::_(
+                    'Please wait %d seconds between each post.',
+                    $this->_conf->getKey('limit', 'traffic')
+                )
+            );
             return;
         }
 
@@ -346,10 +342,14 @@ class Controller
         header('Last-Modified: ' . $time);
         header('Vary: Accept');
         header('Content-Security-Policy: ' . $this->_conf->getKey('cspheader'));
+        header('Cross-Origin-Resource-Policy: same-origin');
+        header('Cross-Origin-Embedder-Policy: require-corp');
+        header('Cross-Origin-Opener-Policy: same-origin');
+        header('Permissions-Policy: interest-cohort=()');
         header('Referrer-Policy: no-referrer');
-        header('X-Xss-Protection: 1; mode=block');
-        header('X-Frame-Options: DENY');
         header('X-Content-Type-Options: nosniff');
+        header('X-Frame-Options: deny');
+        header('X-XSS-Protection: 1; mode=block');
 
         // label all the expiration options
         $expire = array();
@@ -364,7 +364,7 @@ class Controller
         $languageselection = '';
         if ($this->_conf->getKey('languageselection')) {
             $languageselection = I18n::getLanguage();
-            setcookie('lang', $languageselection);
+            setcookie('lang', $languageselection, 0, '', '', true);
         }
 
         $page = new View;
@@ -380,6 +380,7 @@ class Controller
         $page->assign('SYNTAXHIGHLIGHTINGTHEME', $this->_conf->getKey('syntaxhighlightingtheme'));
         $page->assign('FORMATTER', $formatters);
         $page->assign('FORMATTERDEFAULT', $this->_conf->getKey('defaultformatter'));
+        $page->assign('INFO', I18n::_(str_replace("'", '"', $this->_conf->getKey('info'))));
         $page->assign('NOTICE', I18n::_($this->_conf->getKey('notice')));
         $page->assign('BURNAFTERREADINGSELECTED', $this->_conf->getKey('burnafterreadingselected'));
         $page->assign('PASSWORD', $this->_conf->getKey('password'));

+ 57 - 8
lib/Data/AbstractData.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Data;
@@ -15,12 +15,12 @@ namespace PrivateBin\Data;
 /**
  * AbstractData
  *
- * Abstract model for PrivateBin data access, implemented as a singleton.
+ * Abstract model for data access, implemented as a singleton.
  */
 abstract class AbstractData
 {
     /**
-     * singleton instance
+     * Singleton instance
      *
      * @access protected
      * @static
@@ -29,9 +29,18 @@ abstract class AbstractData
     protected static $_instance = null;
 
     /**
-     * enforce singleton, disable constructor
+     * cache for the traffic limiter
      *
-     * Instantiate using {@link getInstance()}, privatebin is a singleton object.
+     * @access private
+     * @static
+     * @var    array
+     */
+    protected static $_last_cache = array();
+
+    /**
+     * Enforce singleton, disable constructor
+     *
+     * Instantiate using {@link getInstance()}, this object implements the singleton pattern.
      *
      * @access protected
      */
@@ -40,9 +49,9 @@ abstract class AbstractData
     }
 
     /**
-     * enforce singleton, disable cloning
+     * Enforce singleton, disable cloning
      *
-     * Instantiate using {@link getInstance()}, privatebin is a singleton object.
+     * Instantiate using {@link getInstance()}, this object implements the singleton pattern.
      *
      * @access private
      */
@@ -51,7 +60,7 @@ abstract class AbstractData
     }
 
     /**
-     * get instance of singleton
+     * Get instance of singleton
      *
      * @access public
      * @static
@@ -130,6 +139,46 @@ abstract class AbstractData
      */
     abstract public function existsComment($pasteid, $parentid, $commentid);
 
+    /**
+     * Purge outdated entries.
+     *
+     * @access public
+     * @param  string $namespace
+     * @param  int $time
+     * @return void
+     */
+    public function purgeValues($namespace, $time)
+    {
+        if ($namespace === 'traffic_limiter') {
+            foreach (self::$_last_cache as $key => $last_submission) {
+                if ($last_submission <= $time) {
+                    unset(self::$_last_cache[$key]);
+                }
+            }
+        }
+    }
+
+    /**
+     * Save a value.
+     *
+     * @access public
+     * @param  string $value
+     * @param  string $namespace
+     * @param  string $key
+     * @return bool
+     */
+    abstract public function setValue($value, $namespace, $key = '');
+
+    /**
+     * Load a value.
+     *
+     * @access public
+     * @param  string $namespace
+     * @param  string $key
+     * @return string
+     */
+    abstract public function getValue($namespace, $key = '');
+
     /**
      * Returns up to batch size number of paste ids that have expired
      *

+ 130 - 44
lib/Data/Database.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Data;
@@ -198,21 +198,25 @@ class Database extends AbstractData
             $opendiscussion   = $paste['adata'][2];
             $burnafterreading = $paste['adata'][3];
         }
-        return self::_exec(
-            'INSERT INTO ' . self::_sanitizeIdentifier('paste') .
-            ' VALUES(?,?,?,?,?,?,?,?,?)',
-            array(
-                $pasteid,
-                $isVersion1 ? $paste['data'] : Json::encode($paste),
-                $created,
-                $expire_date,
-                (int) $opendiscussion,
-                (int) $burnafterreading,
-                Json::encode($meta),
-                $attachment,
-                $attachmentname,
-            )
-        );
+        try {
+            return self::_exec(
+                'INSERT INTO ' . self::_sanitizeIdentifier('paste') .
+                ' VALUES(?,?,?,?,?,?,?,?,?)',
+                array(
+                    $pasteid,
+                    $isVersion1 ? $paste['data'] : Json::encode($paste),
+                    $created,
+                    $expire_date,
+                    (int) $opendiscussion,
+                    (int) $burnafterreading,
+                    Json::encode($meta),
+                    $attachment,
+                    $attachmentname,
+                )
+            );
+        } catch (Exception $e) {
+            return false;
+        }
     }
 
     /**
@@ -229,11 +233,14 @@ class Database extends AbstractData
         }
 
         self::$_cache[$pasteid] = false;
-        $paste                  = self::_select(
-            'SELECT * FROM ' . self::_sanitizeIdentifier('paste') .
-            ' WHERE dataid = ?', array($pasteid), true
-        );
-
+        try {
+            $paste = self::_select(
+                'SELECT * FROM ' . self::_sanitizeIdentifier('paste') .
+                ' WHERE dataid = ?', array($pasteid), true
+            );
+        } catch (Exception $e) {
+            $paste = false;
+        }
         if ($paste === false) {
             return false;
         }
@@ -348,19 +355,23 @@ class Database extends AbstractData
                 $meta[$key] = null;
             }
         }
-        return self::_exec(
-            'INSERT INTO ' . self::_sanitizeIdentifier('comment') .
-            ' VALUES(?,?,?,?,?,?,?)',
-            array(
-                $commentid,
-                $pasteid,
-                $parentid,
-                $data,
-                $meta['nickname'],
-                $meta[$iconKey],
-                $meta[$createdKey],
-            )
-        );
+        try {
+            return self::_exec(
+                'INSERT INTO ' . self::_sanitizeIdentifier('comment') .
+                ' VALUES(?,?,?,?,?,?,?)',
+                array(
+                    $commentid,
+                    $pasteid,
+                    $parentid,
+                    $data,
+                    $meta['nickname'],
+                    $meta[$iconKey],
+                    $meta[$createdKey],
+                )
+            );
+        } catch (Exception $e) {
+            return false;
+        }
     }
 
     /**
@@ -416,13 +427,85 @@ class Database extends AbstractData
      */
     public function existsComment($pasteid, $parentid, $commentid)
     {
-        return (bool) self::_select(
-            'SELECT dataid FROM ' . self::_sanitizeIdentifier('comment') .
-            ' WHERE pasteid = ? AND parentid = ? AND dataid = ?',
-            array($pasteid, $parentid, $commentid), true
+        try {
+            return (bool) self::_select(
+                'SELECT dataid FROM ' . self::_sanitizeIdentifier('comment') .
+                ' WHERE pasteid = ? AND parentid = ? AND dataid = ?',
+                array($pasteid, $parentid, $commentid), true
+            );
+        } catch (Exception $e) {
+            return false;
+        }
+    }
+
+    /**
+     * Save a value.
+     *
+     * @access public
+     * @param  string $value
+     * @param  string $namespace
+     * @param  string $key
+     * @return bool
+     */
+    public function setValue($value, $namespace, $key = '')
+    {
+        if ($namespace === 'traffic_limiter') {
+            self::$_last_cache[$key] = $value;
+            try {
+                $value = Json::encode(self::$_last_cache);
+            } catch (Exception $e) {
+                return false;
+            }
+        }
+        return self::_exec(
+            'UPDATE ' . self::_sanitizeIdentifier('config') .
+            ' SET value = ? WHERE id = ?',
+            array($value, strtoupper($namespace))
         );
     }
 
+    /**
+     * Load a value.
+     *
+     * @access public
+     * @param  string $namespace
+     * @param  string $key
+     * @return string
+     */
+    public function getValue($namespace, $key = '')
+    {
+        $configKey = strtoupper($namespace);
+        $value     = $this->_getConfig($configKey);
+        if ($value === '') {
+            // initialize the row, so that setValue can rely on UPDATE queries
+            self::_exec(
+                'INSERT INTO ' . self::_sanitizeIdentifier('config') .
+                ' VALUES(?,?)',
+                array($configKey, '')
+            );
+
+            // migrate filesystem based salt into database
+            $file = 'data' . DIRECTORY_SEPARATOR . 'salt.php';
+            if ($namespace === 'salt' && is_readable($file)) {
+                $value = Filesystem::getInstance(array('dir' => 'data'))->getValue('salt');
+                $this->setValue($value, 'salt');
+                @unlink($file);
+                return $value;
+            }
+        }
+        if ($value && $namespace === 'traffic_limiter') {
+            try {
+                self::$_last_cache = Json::decode($value);
+            } catch (Exception $e) {
+                self::$_last_cache = array();
+            }
+            if (array_key_exists($key, self::$_last_cache)) {
+                return self::$_last_cache[$key];
+            }
+        }
+        return (string) $value;
+    }
+
     /**
      * Returns up to batch size number of paste ids that have expired
      *
@@ -563,16 +646,19 @@ class Database extends AbstractData
      * @access private
      * @static
      * @param  string $key
-     * @throws PDOException
      * @return string
      */
     private static function _getConfig($key)
     {
-        $row = self::_select(
-            'SELECT value FROM ' . self::_sanitizeIdentifier('config') .
-            ' WHERE id = ?', array($key), true
-        );
-        return $row['value'];
+        try {
+            $row = self::_select(
+                'SELECT value FROM ' . self::_sanitizeIdentifier('config') .
+                ' WHERE id = ?', array($key), true
+            );
+        } catch (PDOException $e) {
+            return '';
+        }
+        return $row ? $row['value'] : '';
     }
 
     /**

+ 227 - 20
lib/Data/Filesystem.php

@@ -7,12 +7,13 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Data;
 
-use PrivateBin\Persistence\DataStore;
+use Exception;
+use PrivateBin\Json;
 
 /**
  * Filesystem
@@ -21,6 +22,29 @@ use PrivateBin\Persistence\DataStore;
  */
 class Filesystem extends AbstractData
 {
+    /**
+     * first line in paste or comment files, to protect their contents from browsing exposed data directories
+     *
+     * @const string
+     */
+    const PROTECTION_LINE = '<?php http_response_code(403); /*';
+
+    /**
+     * line in generated .htaccess files, to protect exposed directories from being browsable on apache web servers
+     *
+     * @const string
+     */
+    const HTACCESS_LINE = 'Require all denied';
+
+    /**
+     * path in which to persist something
+     *
+     * @access private
+     * @static
+     * @var    string
+     */
+    private static $_path = 'data';
+
     /**
      * get instance of singleton
      *
@@ -40,7 +64,7 @@ class Filesystem extends AbstractData
             is_array($options) &&
             array_key_exists('dir', $options)
         ) {
-            DataStore::setPath($options['dir']);
+            self::$_path = $options['dir'];
         }
         return self::$_instance;
     }
@@ -63,7 +87,7 @@ class Filesystem extends AbstractData
         if (!is_dir($storagedir)) {
             mkdir($storagedir, 0700, true);
         }
-        return DataStore::store($file, $paste);
+        return self::_store($file, $paste);
     }
 
     /**
@@ -75,12 +99,13 @@ class Filesystem extends AbstractData
      */
     public function read($pasteid)
     {
-        if (!$this->exists($pasteid)) {
+        if (
+            !$this->exists($pasteid) ||
+            !$paste = self::_get(self::_dataid2path($pasteid) . $pasteid . '.php')
+        ) {
             return false;
         }
-        return self::upgradePreV1Format(
-            DataStore::get(self::_dataid2path($pasteid) . $pasteid . '.php')
-        );
+        return self::upgradePreV1Format($paste);
     }
 
     /**
@@ -127,7 +152,7 @@ class Filesystem extends AbstractData
         $pastePath = $basePath . '.php';
         // convert to PHP protected files if needed
         if (is_readable($basePath)) {
-            DataStore::prependRename($basePath, $pastePath);
+            self::_prependRename($basePath, $pastePath);
 
             // convert comments, too
             $discdir = self::_dataid2discussionpath($pasteid);
@@ -136,7 +161,7 @@ class Filesystem extends AbstractData
                 while (false !== ($filename = $dir->read())) {
                     if (substr($filename, -4) !== '.php' && strlen($filename) >= 16) {
                         $commentFilename = $discdir . $filename . '.php';
-                        DataStore::prependRename($discdir . $filename, $commentFilename);
+                        self::_prependRename($discdir . $filename, $commentFilename);
                     }
                 }
                 $dir->close();
@@ -165,7 +190,7 @@ class Filesystem extends AbstractData
         if (!is_dir($storagedir)) {
             mkdir($storagedir, 0700, true);
         }
-        return DataStore::store($file, $comment);
+        return self::_store($file, $comment);
     }
 
     /**
@@ -187,7 +212,7 @@ class Filesystem extends AbstractData
                 // - commentid is the comment identifier itself.
                 // - parentid is the comment this comment replies to (It can be pasteid)
                 if (is_file($discdir . $filename)) {
-                    $comment = DataStore::get($discdir . $filename);
+                    $comment = self::_get($discdir . $filename);
                     $items   = explode('.', $filename);
                     // Add some meta information not contained in file.
                     $comment['id']       = $items[1];
@@ -223,6 +248,97 @@ class Filesystem extends AbstractData
         );
     }
 
+    /**
+     * Save a value.
+     *
+     * @access public
+     * @param  string $value
+     * @param  string $namespace
+     * @param  string $key
+     * @return bool
+     */
+    public function setValue($value, $namespace, $key = '')
+    {
+        switch ($namespace) {
+            case 'purge_limiter':
+                return self::_storeString(
+                    self::$_path . DIRECTORY_SEPARATOR . 'purge_limiter.php',
+                    '<?php' . PHP_EOL . '$GLOBALS[\'purge_limiter\'] = ' . $value . ';'
+                );
+            case 'salt':
+                return self::_storeString(
+                    self::$_path . DIRECTORY_SEPARATOR . 'salt.php',
+                    '<?php # |' . $value . '|'
+                );
+            case 'traffic_limiter':
+                self::$_last_cache[$key] = $value;
+                return self::_storeString(
+                    self::$_path . DIRECTORY_SEPARATOR . 'traffic_limiter.php',
+                    '<?php' . PHP_EOL . '$GLOBALS[\'traffic_limiter\'] = ' . var_export(self::$_last_cache, true) . ';'
+                );
+        }
+        return false;
+    }
+
+    /**
+     * Load a value.
+     *
+     * @access public
+     * @param  string $namespace
+     * @param  string $key
+     * @return string
+     */
+    public function getValue($namespace, $key = '')
+    {
+        switch ($namespace) {
+            case 'purge_limiter':
+                $file = self::$_path . DIRECTORY_SEPARATOR . 'purge_limiter.php';
+                if (is_readable($file)) {
+                    require $file;
+                    return $GLOBALS['purge_limiter'];
+                }
+                break;
+            case 'salt':
+                $file = self::$_path . DIRECTORY_SEPARATOR . 'salt.php';
+                if (is_readable($file)) {
+                    $items = explode('|', file_get_contents($file));
+                    if (is_array($items) && count($items) == 3) {
+                        return $items[1];
+                    }
+                }
+                break;
+            case 'traffic_limiter':
+                $file = self::$_path . DIRECTORY_SEPARATOR . 'traffic_limiter.php';
+                if (is_readable($file)) {
+                    require $file;
+                    self::$_last_cache = $GLOBALS['traffic_limiter'];
+                    if (array_key_exists($key, self::$_last_cache)) {
+                        return self::$_last_cache[$key];
+                    }
+                }
+                break;
+        }
+        return '';
+    }
+
+    /**
+     * get the data
+     *
+     * @access public
+     * @static
+     * @param  string $filename
+     * @return array|false $data
+     */
+    private static function _get($filename)
+    {
+        return Json::decode(
+            substr(
+                file_get_contents($filename),
+                strlen(self::PROTECTION_LINE . PHP_EOL)
+            )
+        );
+    }
+
     /**
      * Returns up to batch size number of paste ids that have expired
      *
@@ -233,9 +349,8 @@ class Filesystem extends AbstractData
     protected function _getExpiredPastes($batchsize)
     {
         $pastes     = array();
-        $mainpath   = DataStore::getPath();
         $firstLevel = array_filter(
-            scandir($mainpath),
+            scandir(self::$_path),
             'self::_isFirstLevelDir'
         );
         if (count($firstLevel) > 0) {
@@ -243,7 +358,7 @@ class Filesystem extends AbstractData
             for ($i = 0, $max = $batchsize * 10; $i < $max; ++$i) {
                 $firstKey    = array_rand($firstLevel);
                 $secondLevel = array_filter(
-                    scandir($mainpath . DIRECTORY_SEPARATOR . $firstLevel[$firstKey]),
+                    scandir(self::$_path . DIRECTORY_SEPARATOR . $firstLevel[$firstKey]),
                     'self::_isSecondLevelDir'
                 );
 
@@ -254,7 +369,7 @@ class Filesystem extends AbstractData
                 }
 
                 $secondKey = array_rand($secondLevel);
-                $path      = $mainpath . DIRECTORY_SEPARATOR .
+                $path      = self::$_path . DIRECTORY_SEPARATOR .
                     $firstLevel[$firstKey] . DIRECTORY_SEPARATOR .
                     $secondLevel[$secondKey];
                 if (!is_dir($path)) {
@@ -314,10 +429,9 @@ class Filesystem extends AbstractData
      */
     private static function _dataid2path($dataid)
     {
-        return DataStore::getPath(
+        return self::$_path . DIRECTORY_SEPARATOR .
             substr($dataid, 0, 2) . DIRECTORY_SEPARATOR .
-            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR
-        );
+            substr($dataid, 2, 2) . DIRECTORY_SEPARATOR;
     }
 
     /**
@@ -347,7 +461,7 @@ class Filesystem extends AbstractData
     private static function _isFirstLevelDir($element)
     {
         return self::_isSecondLevelDir($element) &&
-            is_dir(DataStore::getPath($element));
+            is_dir(self::$_path . DIRECTORY_SEPARATOR . $element);
     }
 
     /**
@@ -362,4 +476,97 @@ class Filesystem extends AbstractData
     {
         return (bool) preg_match('/^[a-f0-9]{2}$/', $element);
     }
+
+    /**
+     * store the data
+     *
+     * @access public
+     * @static
+     * @param  string $filename
+     * @param  array  $data
+     * @return bool
+     */
+    private static function _store($filename, array $data)
+    {
+        try {
+            return self::_storeString(
+                $filename,
+                self::PROTECTION_LINE . PHP_EOL . Json::encode($data)
+            );
+        } catch (Exception $e) {
+            return false;
+        }
+    }
+
+    /**
+     * store a string
+     *
+     * @access public
+     * @static
+     * @param  string $filename
+     * @param  string $data
+     * @return bool
+     */
+    private static function _storeString($filename, $data)
+    {
+        // Create storage directory if it does not exist.
+        if (!is_dir(self::$_path)) {
+            if (!@mkdir(self::$_path, 0700)) {
+                return false;
+            }
+        }
+        $file = self::$_path . DIRECTORY_SEPARATOR . '.htaccess';
+        if (!is_file($file)) {
+            $writtenBytes = 0;
+            if ($fileCreated = @touch($file)) {
+                $writtenBytes = @file_put_contents(
+                    $file,
+                    self::HTACCESS_LINE . PHP_EOL,
+                    LOCK_EX
+                );
+            }
+            if (
+                $fileCreated === false ||
+                $writtenBytes === false ||
+                $writtenBytes < strlen(self::HTACCESS_LINE . PHP_EOL)
+            ) {
+                return false;
+            }
+        }
+
+        $fileCreated  = true;
+        $writtenBytes = 0;
+        if (!is_file($filename)) {
+            $fileCreated = @touch($filename);
+        }
+        if ($fileCreated) {
+            $writtenBytes = @file_put_contents($filename, $data, LOCK_EX);
+        }
+        if ($fileCreated === false || $writtenBytes === false || $writtenBytes < strlen($data)) {
+            return false;
+        }
+        @chmod($filename, 0640); // protect file from access by other users on the host
+        return true;
+    }
+
+    /**
+     * rename a file, prepending the protection line at the beginning
+     *
+     * @access public
+     * @static
+     * @param  string $srcFile
+     * @param  string $destFile
+     * @return void
+     */
+    private static function _prependRename($srcFile, $destFile)
+    {
+        // don't overwrite already converted file
+        if (!is_readable($destFile)) {
+            $handle = fopen($srcFile, 'r', false, stream_context_create());
+            file_put_contents($destFile, self::PROTECTION_LINE . PHP_EOL);
+            file_put_contents($destFile, $handle, FILE_APPEND);
+            fclose($handle);
+        }
+        unlink($srcFile);
+    }
 }

+ 346 - 0
lib/Data/GoogleCloudStorage.php

@@ -0,0 +1,346 @@
+<?php
+
+namespace PrivateBin\Data;
+
+use Exception;
+use Google\Cloud\Core\Exception\NotFoundException;
+use Google\Cloud\Storage\Bucket;
+use Google\Cloud\Storage\StorageClient;
+use PrivateBin\Json;
+
+class GoogleCloudStorage extends AbstractData
+{
+    /**
+     * GCS client
+     *
+     * @access private
+     * @static
+     * @var    StorageClient
+     */
+    private static $_client = null;
+
+    /**
+     * GCS bucket
+     *
+     * @access private
+     * @static
+     * @var    Bucket
+     */
+    private static $_bucket = null;
+
+    /**
+     * object prefix
+     *
+     * @access private
+     * @static
+     * @var    string
+     */
+    private static $_prefix = 'pastes';
+
+    /**
+     * returns a Google Cloud Storage data backend.
+     *
+     * @access public
+     * @static
+     * @param array $options
+     * @return GoogleCloudStorage
+     */
+    public static function getInstance(array $options)
+    {
+        // if needed initialize the singleton
+        if (!(self::$_instance instanceof self)) {
+            self::$_instance = new self;
+        }
+
+        $bucket = null;
+        if (getenv('PRIVATEBIN_GCS_BUCKET')) {
+            $bucket = getenv('PRIVATEBIN_GCS_BUCKET');
+        }
+        if (is_array($options) && array_key_exists('bucket', $options)) {
+            $bucket = $options['bucket'];
+        }
+        if (is_array($options) && array_key_exists('prefix', $options)) {
+            self::$_prefix = $options['prefix'];
+        }
+
+        if (empty(self::$_client)) {
+            self::$_client = class_exists('StorageClientStub', false) ?
+                new \StorageClientStub(array()) :
+                new StorageClient(array('suppressKeyFileNotice' => true));
+        }
+        self::$_bucket = self::$_client->bucket($bucket);
+
+        return self::$_instance;
+    }
+
+    /**
+     * returns the google storage object key for $pasteid in self::$_bucket.
+     *
+     * @access private
+     * @param $pasteid string to get the key for
+     * @return string
+     */
+    private function _getKey($pasteid)
+    {
+        if (self::$_prefix != '') {
+            return self::$_prefix . '/' . $pasteid;
+        }
+        return $pasteid;
+    }
+
+    /**
+     * Uploads the payload in the self::$_bucket under the specified key.
+     * The entire payload is stored as a JSON document. The metadata is replicated
+     * as the GCS object's metadata except for the fields attachment, attachmentname
+     * and salt.
+     *
+     * @param $key string to store the payload under
+     * @param $payload array to store
+     * @return bool true if successful, otherwise false.
+     */
+    private function _upload($key, $payload)
+    {
+        $metadata = array_key_exists('meta', $payload) ? $payload['meta'] : array();
+        unset($metadata['attachment'], $metadata['attachmentname'], $metadata['salt']);
+        foreach ($metadata as $k => $v) {
+            $metadata[$k] = strval($v);
+        }
+        try {
+            self::$_bucket->upload(Json::encode($payload), array(
+                'name'          => $key,
+                'chunkSize'     => 262144,
+                'predefinedAcl' => 'private',
+                'metadata'      => array(
+                    'content-type' => 'application/json',
+                    'metadata'     => $metadata,
+                ),
+            ));
+        } catch (Exception $e) {
+            error_log('failed to upload ' . $key . ' to ' . self::$_bucket->name() . ', ' .
+                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function create($pasteid, array $paste)
+    {
+        if ($this->exists($pasteid)) {
+            return false;
+        }
+
+        return $this->_upload($this->_getKey($pasteid), $paste);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function read($pasteid)
+    {
+        try {
+            $o    = self::$_bucket->object($this->_getKey($pasteid));
+            $data = $o->downloadAsString();
+            return Json::decode($data);
+        } catch (NotFoundException $e) {
+            return false;
+        } catch (Exception $e) {
+            error_log('failed to read ' . $pasteid . ' from ' . self::$_bucket->name() . ', ' .
+                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
+            return false;
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function delete($pasteid)
+    {
+        $name = $this->_getKey($pasteid);
+
+        try {
+            foreach (self::$_bucket->objects(array('prefix' => $name . '/discussion/')) as $comment) {
+                try {
+                    self::$_bucket->object($comment->name())->delete();
+                } catch (NotFoundException $e) {
+                    // ignore if already deleted.
+                }
+            }
+        } catch (NotFoundException $e) {
+            // there are no discussions associated with the paste
+        }
+
+        try {
+            self::$_bucket->object($name)->delete();
+        } catch (NotFoundException $e) {
+            // ignore if already deleted
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function exists($pasteid)
+    {
+        $o = self::$_bucket->object($this->_getKey($pasteid));
+        return $o->exists();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function createComment($pasteid, $parentid, $commentid, array $comment)
+    {
+        if ($this->existsComment($pasteid, $parentid, $commentid)) {
+            return false;
+        }
+        $key = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
+        return $this->_upload($key, $comment);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function readComments($pasteid)
+    {
+        $comments = array();
+        $prefix   = $this->_getKey($pasteid) . '/discussion/';
+        try {
+            foreach (self::$_bucket->objects(array('prefix' => $prefix)) as $key) {
+                $comment         = JSON::decode(self::$_bucket->object($key->name())->downloadAsString());
+                $comment['id']   = basename($key->name());
+                $slot            = $this->getOpenSlot($comments, (int) $comment['meta']['created']);
+                $comments[$slot] = $comment;
+            }
+        } catch (NotFoundException $e) {
+            // no comments found
+        }
+        return $comments;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function existsComment($pasteid, $parentid, $commentid)
+    {
+        $name = $this->_getKey($pasteid) . '/discussion/' . $parentid . '/' . $commentid;
+        $o    = self::$_bucket->object($name);
+        return $o->exists();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function purgeValues($namespace, $time)
+    {
+        $path = 'config/' . $namespace;
+        try {
+            foreach (self::$_bucket->objects(array('prefix' => $path)) as $object) {
+                $name = $object->name();
+                if (strlen($name) > strlen($path) && substr($name, strlen($path), 1) !== '/') {
+                    continue;
+                }
+                $info = $object->info();
+                if (key_exists('metadata', $info) && key_exists('value', $info['metadata'])) {
+                    $value = $info['metadata']['value'];
+                    if (is_numeric($value) && intval($value) < $time) {
+                        try {
+                            $object->delete();
+                        } catch (NotFoundException $e) {
+                            // deleted by another instance.
+                        }
+                    }
+                }
+            }
+        } catch (NotFoundException $e) {
+            // no objects in the bucket yet
+        }
+    }
+
+    /**
+     * For GoogleCloudStorage, the value will also be stored in the metadata for the
+     * namespaces traffic_limiter and purge_limiter.
+     * @inheritDoc
+     */
+    public function setValue($value, $namespace, $key = '')
+    {
+        if ($key === '') {
+            $key = 'config/' . $namespace;
+        } else {
+            $key = 'config/' . $namespace . '/' . $key;
+        }
+
+        $metadata = array('namespace' => $namespace);
+        if ($namespace != 'salt') {
+            $metadata['value'] = strval($value);
+        }
+        try {
+            self::$_bucket->upload($value, array(
+                'name'          => $key,
+                'chunkSize'     => 262144,
+                'predefinedAcl' => 'private',
+                'metadata'      => array(
+                    'content-type' => 'application/json',
+                    'metadata'     => $metadata,
+                ),
+            ));
+        } catch (Exception $e) {
+            error_log('failed to set key ' . $key . ' to ' . self::$_bucket->name() . ', ' .
+                trim(preg_replace('/\s\s+/', ' ', $e->getMessage())));
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getValue($namespace, $key = '')
+    {
+        if ($key === '') {
+            $key = 'config/' . $namespace;
+        } else {
+            $key = 'config/' . $namespace . '/' . $key;
+        }
+        try {
+            $o = self::$_bucket->object($key);
+            return $o->downloadAsString();
+        } catch (NotFoundException $e) {
+            return '';
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _getExpiredPastes($batchsize)
+    {
+        $expired = array();
+
+        $now    = time();
+        $prefix = self::$_prefix;
+        if ($prefix != '') {
+            $prefix .= '/';
+        }
+        try {
+            foreach (self::$_bucket->objects(array('prefix' => $prefix)) as $object) {
+                $metadata = $object->info()['metadata'];
+                if ($metadata != null && array_key_exists('expire_date', $metadata)) {
+                    $expire_at = intval($metadata['expire_date']);
+                    if ($expire_at != 0 && $expire_at < $now) {
+                        array_push($expired, basename($object->name()));
+                    }
+                }
+
+                if (count($expired) > $batchsize) {
+                    break;
+                }
+            }
+        } catch (NotFoundException $e) {
+            // no objects in the bucket yet
+        }
+        return $expired;
+    }
+}

+ 1 - 1
lib/Filter.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;

+ 6 - 1
lib/FormatV2.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;
@@ -52,6 +52,11 @@ class FormatV2
             }
         }
 
+        // Make sure adata is an array.
+        if (!is_array($message['adata'])) {
+            return false;
+        }
+
         $cipherParams = $isComment ? $message['adata'] : $message['adata'][0];
 
         // Make sure some fields are base64 data:

+ 9 - 3
lib/I18n.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;
@@ -305,7 +305,7 @@ class I18n
     /**
      * determines the plural form to use based on current language and given number
      *
-     * From: http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html
+     * From: https://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html
      *
      * @access protected
      * @static
@@ -321,6 +321,12 @@ class I18n
             case 'oc':
             case 'zh':
                 return $n > 1 ? 1 : 0;
+            case 'he':
+                return $n === 1 ? 0 : ($n === 2 ? 1 : (($n < 0 || $n > 10) && ($n % 10 === 0) ? 2 : 3));
+            case 'id':
+                return 0;
+            case 'lt':
+                return $n % 10 === 1 && $n % 100 !== 11 ? 0 : (($n % 10 >= 2 && $n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
             case 'pl':
                 return $n == 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
             case 'ru':
@@ -328,7 +334,7 @@ class I18n
                 return $n % 10 == 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2);
             case 'sl':
                 return $n % 100 == 1 ? 1 : ($n % 100 == 2 ? 2 : ($n % 100 == 3 || $n % 100 == 4 ? 3 : 0));
-            // bg, de, en, es, hu, it, nl, no, pt
+            // bg, ca, de, en, es, et, hu, it, nl, no, pt
             default:
                 return $n != 1 ? 1 : 0;
         }

+ 4 - 4
lib/Json.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;
@@ -44,13 +44,13 @@ class Json
      * @static
      * @param  string $input
      * @throws Exception
-     * @return array
+     * @return mixed
      */
     public static function decode($input)
     {
-        $array = json_decode($input, true);
+        $output = json_decode($input, true);
         self::_detectError();
-        return $array;
+        return $output;
     }
 
     /**

+ 5 - 4
lib/Model.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;
@@ -54,7 +54,7 @@ class Model
      */
     public function getPaste($pasteId = null)
     {
-        $paste = new Paste($this->_conf, $this->_getStore());
+        $paste = new Paste($this->_conf, $this->getStore());
         if ($pasteId !== null) {
             $paste->setId($pasteId);
         }
@@ -67,8 +67,9 @@ class Model
     public function purge()
     {
         PurgeLimiter::setConfiguration($this->_conf);
+        PurgeLimiter::setStore($this->getStore());
         if (PurgeLimiter::canPurge()) {
-            $this->_getStore()->purge($this->_conf->getKey('batchsize', 'purge'));
+            $this->getStore()->purge($this->_conf->getKey('batchsize', 'purge'));
         }
     }
 
@@ -77,7 +78,7 @@ class Model
      *
      * @return Data\AbstractData
      */
-    private function _getStore()
+    public function getStore()
     {
         if ($this->_store === null) {
             $this->_store = forward_static_call(

+ 1 - 1
lib/Model/AbstractModel.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Model;

+ 1 - 1
lib/Model/Comment.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Model;

+ 2 - 2
lib/Model/Paste.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Model;
@@ -93,7 +93,7 @@ class Paste extends AbstractModel
         }
 
         $this->_data['meta']['created'] = time();
-        $this->_data['meta']['salt']    = serversalt::generate();
+        $this->_data['meta']['salt']    = ServerSalt::generate();
 
         // store paste
         if (

+ 8 - 89
lib/Persistence/AbstractPersistence.php

@@ -7,12 +7,12 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Persistence;
 
-use Exception;
+use PrivateBin\Data\AbstractData;
 
 /**
  * AbstractPersistence
@@ -22,104 +22,23 @@ use Exception;
 abstract class AbstractPersistence
 {
     /**
-     * path in which to persist something
+     * data storage to use to persist something
      *
      * @access private
      * @static
-     * @var    string
+     * @var AbstractData
      */
-    private static $_path = 'data';
+    protected static $_store;
 
     /**
      * set the path
      *
      * @access public
      * @static
-     * @param  string $path
+     * @param  AbstractData $store
      */
-    public static function setPath($path)
+    public static function setStore(AbstractData $store)
     {
-        self::$_path = $path;
-    }
-
-    /**
-     * get the path
-     *
-     * @access public
-     * @static
-     * @param  string $filename
-     * @return string
-     */
-    public static function getPath($filename = null)
-    {
-        if (strlen($filename)) {
-            return self::$_path . DIRECTORY_SEPARATOR . $filename;
-        } else {
-            return self::$_path;
-        }
-    }
-
-    /**
-     * checks if the file exists
-     *
-     * @access protected
-     * @static
-     * @param  string $filename
-     * @return bool
-     */
-    protected static function _exists($filename)
-    {
-        self::_initialize();
-        return is_file(self::$_path . DIRECTORY_SEPARATOR . $filename);
-    }
-
-    /**
-     * prepares path for storage
-     *
-     * @access protected
-     * @static
-     * @throws Exception
-     */
-    protected static function _initialize()
-    {
-        // Create storage directory if it does not exist.
-        if (!is_dir(self::$_path)) {
-            if (!@mkdir(self::$_path, 0700)) {
-                throw new Exception('unable to create directory ' . self::$_path, 10);
-            }
-        }
-        $file = self::$_path . DIRECTORY_SEPARATOR . '.htaccess';
-        if (!is_file($file)) {
-            $writtenBytes = @file_put_contents(
-                $file,
-                'Require all denied' . PHP_EOL,
-                LOCK_EX
-            );
-            if ($writtenBytes === false || $writtenBytes < 19) {
-                throw new Exception('unable to write to file ' . $file, 11);
-            }
-        }
-    }
-
-    /**
-     * store the data
-     *
-     * @access protected
-     * @static
-     * @param  string $filename
-     * @param  string $data
-     * @throws Exception
-     * @return string
-     */
-    protected static function _store($filename, $data)
-    {
-        self::_initialize();
-        $file         = self::$_path . DIRECTORY_SEPARATOR . $filename;
-        $writtenBytes = @file_put_contents($file, $data, LOCK_EX);
-        if ($writtenBytes === false || $writtenBytes < strlen($data)) {
-            throw new Exception('unable to write to file ' . $file, 13);
-        }
-        @chmod($file, 0640); // protect file access
-        return $file;
+        self::$_store = $store;
     }
 }

+ 0 - 97
lib/Persistence/DataStore.php

@@ -1,97 +0,0 @@
-<?php
-/**
- * PrivateBin
- *
- * a zero-knowledge paste bin
- *
- * @link      https://github.com/PrivateBin/PrivateBin
- * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
- * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
- */
-
-namespace PrivateBin\Persistence;
-
-use Exception;
-use PrivateBin\Json;
-
-/**
- * DataStore
- *
- * Handles data storage for Data\Filesystem.
- */
-class DataStore extends AbstractPersistence
-{
-    /**
-     * first line in file, to protect its contents
-     *
-     * @const string
-     */
-    const PROTECTION_LINE = '<?php http_response_code(403); /*';
-
-    /**
-     * store the data
-     *
-     * @access public
-     * @static
-     * @param  string $filename
-     * @param  array  $data
-     * @return bool
-     */
-    public static function store($filename, $data)
-    {
-        $path = self::getPath();
-        if (strpos($filename, $path) === 0) {
-            $filename = substr($filename, strlen($path));
-        }
-        try {
-            self::_store(
-                $filename,
-                self::PROTECTION_LINE . PHP_EOL . Json::encode($data)
-            );
-            return true;
-        } catch (Exception $e) {
-            return false;
-        }
-    }
-
-    /**
-     * get the data
-     *
-     * @access public
-     * @static
-     * @param  string $filename
-     * @return array|false $data
-     */
-    public static function get($filename)
-    {
-        return Json::decode(
-            substr(
-                file_get_contents($filename),
-                strlen(self::PROTECTION_LINE . PHP_EOL)
-            )
-        );
-    }
-
-    /**
-     * rename a file, prepending the protection line at the beginning
-     *
-     * @access public
-     * @static
-     * @param  string $srcFile
-     * @param  string $destFile
-     * @param  string $prefix (optional)
-     * @return void
-     */
-    public static function prependRename($srcFile, $destFile, $prefix = '')
-    {
-        // don't overwrite already converted file
-        if (!is_readable($destFile)) {
-            $handle = fopen($srcFile, 'r', false, stream_context_create());
-            file_put_contents($destFile, $prefix . self::PROTECTION_LINE . PHP_EOL);
-            file_put_contents($destFile, $handle, FILE_APPEND);
-            fclose($handle);
-        }
-        unlink($srcFile);
-    }
-}

+ 9 - 14
lib/Persistence/PurgeLimiter.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Persistence;
@@ -52,7 +52,6 @@ class PurgeLimiter extends AbstractPersistence
     public static function setConfiguration(Configuration $conf)
     {
         self::setLimit($conf->getKey('limit', 'purge'));
-        self::setPath($conf->getKey('dir', 'purge'));
     }
 
     /**
@@ -60,7 +59,6 @@ class PurgeLimiter extends AbstractPersistence
      *
      * @access public
      * @static
-     * @throws \Exception
      * @return bool
      */
     public static function canPurge()
@@ -71,17 +69,14 @@ class PurgeLimiter extends AbstractPersistence
         }
 
         $now  = time();
-        $file = 'purge_limiter.php';
-        if (self::_exists($file)) {
-            require self::getPath($file);
-            $pl = $GLOBALS['purge_limiter'];
-            if ($pl + self::$_limit >= $now) {
-                return false;
-            }
+        $pl   = (int) self::$_store->getValue('purge_limiter');
+        if ($pl + self::$_limit >= $now) {
+            return false;
         }
-
-        $content = '<?php' . PHP_EOL . '$GLOBALS[\'purge_limiter\'] = ' . $now . ';';
-        self::_store($file, $content);
-        return true;
+        $hasStored = self::$_store->setValue((string) $now, 'purge_limiter');
+        if (!$hasStored) {
+            error_log('failed to store the purge limiter, skipping purge cycle to avoid getting stuck in a purge loop');
+        }
+        return $hasStored;
     }
 }

+ 12 - 29
lib/Persistence/ServerSalt.php

@@ -7,12 +7,12 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Persistence;
 
-use Exception;
+use PrivateBin\Data\AbstractData;
 
 /**
  * ServerSalt
@@ -26,15 +26,6 @@ use Exception;
  */
 class ServerSalt extends AbstractPersistence
 {
-    /**
-     * file where salt is saved to
-     *
-     * @access private
-     * @static
-     * @var    string
-     */
-    private static $_file = 'salt.php';
-
     /**
      * generated salt
      *
@@ -53,8 +44,7 @@ class ServerSalt extends AbstractPersistence
      */
     public static function generate()
     {
-        $randomSalt = bin2hex(random_bytes(256));
-        return $randomSalt;
+        return bin2hex(random_bytes(256));
     }
 
     /**
@@ -62,7 +52,6 @@ class ServerSalt extends AbstractPersistence
      *
      * @access public
      * @static
-     * @throws Exception
      * @return string
      */
     public static function get()
@@ -71,20 +60,14 @@ class ServerSalt extends AbstractPersistence
             return self::$_salt;
         }
 
-        if (self::_exists(self::$_file)) {
-            if (is_readable(self::getPath(self::$_file))) {
-                $items = explode('|', file_get_contents(self::getPath(self::$_file)));
-            }
-            if (!isset($items) || !is_array($items) || count($items) != 3) {
-                throw new Exception('unable to read file ' . self::getPath(self::$_file), 20);
-            }
-            self::$_salt = $items[1];
+        $salt = self::$_store->getValue('salt');
+        if ($salt) {
+            self::$_salt = $salt;
         } else {
             self::$_salt = self::generate();
-            self::_store(
-                self::$_file,
-                '<?php # |' . self::$_salt . '|'
-            );
+            if (!self::$_store->setValue(self::$_salt, 'salt')) {
+                error_log('failed to store the server salt, delete tokens, traffic limiter and user icons won\'t work');
+            }
         }
         return self::$_salt;
     }
@@ -94,11 +77,11 @@ class ServerSalt extends AbstractPersistence
      *
      * @access public
      * @static
-     * @param  string $path
+     * @param  AbstractData $store
      */
-    public static function setPath($path)
+    public static function setStore(AbstractData $store)
     {
         self::$_salt = '';
-        parent::setPath($path);
+        parent::setStore($store);
     }
 }

+ 70 - 24
lib/Persistence/TrafficLimiter.php

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * PrivateBin
  *
@@ -7,11 +8,12 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin\Persistence;
 
+use IPLib\Factory;
 use PrivateBin\Configuration;
 
 /**
@@ -30,6 +32,15 @@ class TrafficLimiter extends AbstractPersistence
      */
     private static $_limit = 10;
 
+    /**
+     * listed ips are exempted from limits, defaults to null
+     *
+     * @access private
+     * @static
+     * @var    string|null
+     */
+    private static $_exemptedIp = null;
+
     /**
      * key to fetch IP address
      *
@@ -51,6 +62,18 @@ class TrafficLimiter extends AbstractPersistence
         self::$_limit = $limit;
     }
 
+    /**
+     * set a list of ip(ranges) as string
+     *
+     * @access public
+     * @static
+     * @param string $exemptedIps
+     */
+    public static function setExemptedIp($exemptedIp)
+    {
+        self::$_exemptedIp = $exemptedIp;
+    }
+
     /**
      * set configuration options of the traffic limiter
      *
@@ -61,7 +84,8 @@ class TrafficLimiter extends AbstractPersistence
     public static function setConfiguration(Configuration $conf)
     {
         self::setLimit($conf->getKey('limit', 'traffic'));
-        self::setPath($conf->getKey('dir', 'traffic'));
+        self::setExemptedIp($conf->getKey('exemptedIp', 'traffic'));
+
         if (($option = $conf->getKey('header', 'traffic')) !== null) {
             $httpHeader = 'HTTP_' . $option;
             if (array_key_exists($httpHeader, $_SERVER) && !empty($_SERVER[$httpHeader])) {
@@ -83,6 +107,34 @@ class TrafficLimiter extends AbstractPersistence
         return hash_hmac($algo, $_SERVER[self::$_ipKey], ServerSalt::get());
     }
 
+    /**
+     * Validate $_ipKey against configured ipranges. If matched we will ignore the ip
+     *
+     * @access private
+     * @static
+     * @param  string $ipRange
+     * @return bool
+     */
+    private static function matchIp($ipRange = null)
+    {
+        if (is_string($ipRange)) {
+            $ipRange = trim($ipRange);
+        }
+        $address = Factory::addressFromString($_SERVER[self::$_ipKey]);
+        $range   = Factory::rangeFromString($ipRange);
+
+        // address could not be parsed, we might not be in IP space and try a string comparison instead
+        if (is_null($address)) {
+            return $_SERVER[self::$_ipKey] === $ipRange;
+        }
+        // range could not be parsed, possibly an invalid ip range given in config
+        if (is_null($range)) {
+            return false;
+        }
+
+        return $address->matches($range);
+    }
+
     /**
      * traffic limiter
      *
@@ -90,7 +142,6 @@ class TrafficLimiter extends AbstractPersistence
      *
      * @access public
      * @static
-     * @throws \Exception
      * @return bool
      */
     public static function canPass()
@@ -100,35 +151,30 @@ class TrafficLimiter extends AbstractPersistence
             return true;
         }
 
-        $file = 'traffic_limiter.php';
-        if (self::_exists($file)) {
-            require self::getPath($file);
-            $tl = $GLOBALS['traffic_limiter'];
-        } else {
-            $tl = array();
-        }
-
-        // purge file of expired hashes to keep it small
-        $now = time();
-        foreach ($tl as $key => $time) {
-            if ($time + self::$_limit < $now) {
-                unset($tl[$key]);
+        // Check if $_ipKey is exempted from ratelimiting
+        if (!is_null(self::$_exemptedIp)) {
+            $exIp_array = explode(',', self::$_exemptedIp);
+            foreach ($exIp_array as $ipRange) {
+                if (self::matchIp($ipRange) === true) {
+                    return true;
+                }
             }
         }
 
         // this hash is used as an array key, hence a shorter algo is used
         $hash = self::getHash('sha256');
-        if (array_key_exists($hash, $tl) && ($tl[$hash] + self::$_limit >= $now)) {
+        $now  = time();
+        $tl   = (int) self::$_store->getValue('traffic_limiter', $hash);
+        self::$_store->purgeValues('traffic_limiter', $now - self::$_limit);
+        if ($tl > 0 && ($tl + self::$_limit >= $now)) {
             $result = false;
         } else {
-            $tl[$hash] = time();
-            $result    = true;
+            $tl     = time();
+            $result = true;
+        }
+        if (!self::$_store->setValue((string) $tl, 'traffic_limiter', $hash)) {
+            error_log('failed to store the traffic limiter, it probably contains outdated information');
         }
-        self::_store(
-            $file,
-            '<?php' . PHP_EOL .
-            '$GLOBALS[\'traffic_limiter\'] = ' . var_export($tl, true) . ';'
-        );
         return $result;
     }
 }

+ 8 - 11
lib/Request.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;
@@ -108,7 +108,9 @@ class Request
             case 'DELETE':
             case 'PUT':
             case 'POST':
-                $this->_params = Json::decode(
+                // it might be a creation or a deletion, the latter is detected below
+                $this->_operation = 'create';
+                $this->_params    = Json::decode(
                     file_get_contents(self::$_inputStream)
                 );
                 break;
@@ -125,15 +127,10 @@ class Request
         }
 
         // prepare operation, depending on current parameters
-        if (
-            array_key_exists('ct', $this->_params) &&
-            !empty($this->_params['ct'])
-        ) {
-            $this->_operation = 'create';
-        } elseif (array_key_exists('pasteid', $this->_params) && !empty($this->_params['pasteid'])) {
+        if (array_key_exists('pasteid', $this->_params) && !empty($this->_params['pasteid'])) {
             if (array_key_exists('deletetoken', $this->_params) && !empty($this->_params['deletetoken'])) {
                 $this->_operation = 'delete';
-            } else {
+            } elseif ($this->_operation != 'create') {
                 $this->_operation = 'read';
             }
         } elseif (array_key_exists('jsonld', $this->_params) && !empty($this->_params['jsonld'])) {
@@ -172,7 +169,7 @@ class Request
             $data['meta'] = $meta;
         }
         foreach ($required_keys as $key) {
-            $data[$key] = $this->getParam($key);
+            $data[$key] = $this->getParam($key, $key == 'v' ? 1 : '');
         }
         // forcing a cast to int or float
         $data['v'] = $data['v'] + 0;
@@ -288,7 +285,7 @@ class Request
             }
             krsort($mediaTypes);
             foreach ($mediaTypes as $acceptedQuality => $acceptedValues) {
-                if ($acceptedQuality === 0.0) {
+                if ($acceptedQuality === '0.0') {
                     continue;
                 }
                 foreach ($acceptedValues as $acceptedValue) {

+ 1 - 1
lib/View.php

@@ -7,7 +7,7 @@
  * @link      https://github.com/PrivateBin/PrivateBin
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   1.3.4
+ * @version   1.3.5
  */
 
 namespace PrivateBin;

+ 1 - 1
lib/Vizhash16x16.php

@@ -8,7 +8,7 @@
  * @link      http://sebsauvage.net/wiki/doku.php?id=php:vizhash_gd
  * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  * @license   https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
- * @version   0.0.5 beta PrivateBin 1.3.4
+ * @version   0.0.5 beta PrivateBin 1.3.5
  */
 
 namespace PrivateBin;

+ 14 - 5
tpl/bootstrap.php

@@ -45,7 +45,7 @@ endif;
 <?php
 if ($QRCODE) :
 ?>
-		<script async type="text/javascript" data-cfasync="false" src="js/kjua-0.6.0.js" integrity="sha512-GEEIHvphDt1NmaxzX8X1ZkBiGKXCv+Ofzwi8SMEH5wQVWqdGIvBO/fnxxKZ90RU1bVp6srS68nHIpZo6iVcG9g==" crossorigin="anonymous"></script>
+		<script async type="text/javascript" data-cfasync="false" src="js/kjua-0.9.0.js" integrity="sha512-CVn7af+vTMBd9RjoS4QM5fpLFEOtBCoB0zPtaqIDC7sF4F8qgUSRFQQpIyEDGsr6yrjbuOLzdf20tkHHmpaqwQ==" crossorigin="anonymous"></script>
 <?php
 endif;
 if ($ZEROBINCOMPATIBILITY) :
@@ -70,9 +70,9 @@ if ($MARKDOWN) :
 <?php
 endif;
 ?>
-		<script type="text/javascript" data-cfasync="false" src="js/purify-2.0.14.js" integrity="sha512-kbLhjIj/m/AW++o2eErCfqPueoX2btJo7VznhEC2YQRbVR/+Eup3w7thwDZwoCZ/gLrPxTX3W4H2KzupLg2PKA==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/purify-2.2.7.js" integrity="sha512-7Ka1I/nJuR2CL8wzIS5PJS4HgEMd0HJ6kfAl6fFhwFBB27rhztFbe0tS+Ex+Qg+5n4nZIT4lty4k4Di3+X9T4A==" crossorigin="anonymous"></script>
 		<script type="text/javascript" data-cfasync="false" src="js/legacy.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-LYos+qXHIRqFf5ZPNphvtTB0cgzHUizu2wwcOwcwz/VIpRv9lpcBgPYz4uq6jx0INwCAj6Fbnl5HoKiLufS2jg==" crossorigin="anonymous"></script>
-		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-GCiSgkYlcyJq3SOMOAh52rIlUAoGH8yDJzOm/NkzBorbk2qiBSjc289/RxpeZJcdu36fQObFTzLvz4Do/2LFsA==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-lJwDAY69TQuYQZ7FjUFPfhgYeZ2L6y5bmGt1hR+d3kMm2sddivGr7ZDdLLSe/CBgn1JrsKMj3th9dPyXN3dLHw==" crossorigin="anonymous"></script>
 		<!-- icon -->
 		<link rel="apple-touch-icon" href="<?php echo I18n::encode($BASEPATH); ?>img/apple-touch-icon.png" sizes="180x180" />
 		<link rel="icon" type="image/png" href="img/favicon-32x32.png" sizes="32x32" />
@@ -212,6 +212,9 @@ endif;
 						<button id="rawtextbutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
 							<span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> <?php echo I18n::_('Raw text'), PHP_EOL; ?>
 						</button>
+						<button id="downloadtextbutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
+							<span class="glyphicon glyphicon glyphicon-download-alt" aria-hidden="true"></span> <?php echo I18n::_('Save paste'), PHP_EOL; ?>
+						</button>
 						<button id="emaillink" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
 							<span class="glyphicon glyphicon-envelope" aria-hidden="true"></span> <?php echo I18n::_('Email'), PHP_EOL; ?>
 						</button>
@@ -422,7 +425,7 @@ if (strlen($LANGUAGESELECTION)) :
 ?>
 					<li id="language" class="dropdown">
 						<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-flag" aria-hidden="true"></span> <?php echo $LANGUAGES[$LANGUAGESELECTION][0]; ?> <span class="caret"></span></a>
-						<ul class="dropdown-menu">
+						<ul class="dropdown-menu dropdown-menu-right">
 <?php
     foreach ($LANGUAGES as $key => $value) :
 ?>
@@ -578,7 +581,13 @@ endif;
 					<h4 class="col-md-5 col-xs-8"><?php echo I18n::_($NAME); ?> <small>- <?php echo I18n::_('Because ignorance is bliss'); ?></small></h4>
 					<p class="col-md-1 col-xs-4 text-center"><?php echo $VERSION; ?></p>
 					<p id="aboutbox" class="col-md-6 col-xs-12">
-						<?php echo I18n::_('%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href="https://privatebin.info/">project page</a>.', I18n::_($NAME)), PHP_EOL; ?>
+						<?php echo sprintf(
+                            I18n::_('%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.',
+                                I18n::_($NAME),
+                                '%s', '%s'
+                            ),
+                            '<i>', '</i>'), ' ', $INFO, PHP_EOL;
+                        ?>
 					</p>
 				</div>
 			</footer>

+ 12 - 4
tpl/page.php

@@ -24,7 +24,7 @@ endif;
 <?php
 if ($QRCODE):
 ?>
-		<script async type="text/javascript" data-cfasync="false" src="js/kjua-0.6.0.js" integrity="sha512-GEEIHvphDt1NmaxzX8X1ZkBiGKXCv+Ofzwi8SMEH5wQVWqdGIvBO/fnxxKZ90RU1bVp6srS68nHIpZo6iVcG9g==" crossorigin="anonymous"></script>
+		<script async type="text/javascript" data-cfasync="false" src="js/kjua-0.9.0.js" integrity="sha512-CVn7af+vTMBd9RjoS4QM5fpLFEOtBCoB0zPtaqIDC7sF4F8qgUSRFQQpIyEDGsr6yrjbuOLzdf20tkHHmpaqwQ==" crossorigin="anonymous"></script>
 <?php
 endif;
 if ($ZEROBINCOMPATIBILITY):
@@ -48,9 +48,9 @@ if ($MARKDOWN):
 <?php
 endif;
 ?>
-		<script type="text/javascript" data-cfasync="false" src="js/purify-2.0.14.js" integrity="sha512-kbLhjIj/m/AW++o2eErCfqPueoX2btJo7VznhEC2YQRbVR/+Eup3w7thwDZwoCZ/gLrPxTX3W4H2KzupLg2PKA==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/purify-2.2.7.js" integrity="sha512-7Ka1I/nJuR2CL8wzIS5PJS4HgEMd0HJ6kfAl6fFhwFBB27rhztFbe0tS+Ex+Qg+5n4nZIT4lty4k4Di3+X9T4A==" crossorigin="anonymous"></script>
 		<script type="text/javascript" data-cfasync="false" src="js/legacy.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-LYos+qXHIRqFf5ZPNphvtTB0cgzHUizu2wwcOwcwz/VIpRv9lpcBgPYz4uq6jx0INwCAj6Fbnl5HoKiLufS2jg==" crossorigin="anonymous"></script>
-		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-GCiSgkYlcyJq3SOMOAh52rIlUAoGH8yDJzOm/NkzBorbk2qiBSjc289/RxpeZJcdu36fQObFTzLvz4Do/2LFsA==" crossorigin="anonymous"></script>
+		<script type="text/javascript" data-cfasync="false" src="js/privatebin.js?<?php echo rawurlencode($VERSION); ?>" integrity="sha512-lJwDAY69TQuYQZ7FjUFPfhgYeZ2L6y5bmGt1hR+d3kMm2sddivGr7ZDdLLSe/CBgn1JrsKMj3th9dPyXN3dLHw==" crossorigin="anonymous"></script>
 		<!-- icon -->
 		<link rel="apple-touch-icon" href="img/apple-touch-icon.png?<?php echo rawurlencode($VERSION); ?>" sizes="180x180" />
 		<link rel="icon" type="image/png" href="img/favicon-32x32.png?<?php echo rawurlencode($VERSION); ?>" sizes="32x32" />
@@ -76,7 +76,14 @@ endif;
 	<body data-compression="<?php echo rawurlencode($COMPRESSION); ?>">
 		<header>
 			<div id="aboutbox">
-				<?php echo I18n::_('%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted <i>in the browser</i> using 256 bits AES. More information on the <a href="https://privatebin.info/">project page</a>.', I18n::_($NAME)); ?><br />
+				<?php echo sprintf(
+                    I18n::_('%s is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.',
+                        I18n::_($NAME),
+                        '%s', '%s'
+                    ),
+                    '<i>', '</i>'), ' ', $INFO;
+                ?>
+				<br />
 <?php
 if (strlen($NOTICE)):
 ?>
@@ -120,6 +127,7 @@ endif;
 					<button id="sendbutton" class="hidden"><img src="img/icon_send.png" width="18" height="15" alt="" /><?php echo I18n::_('Send'); ?></button>
 					<button id="clonebutton" class="hidden"><img src="img/icon_clone.png" width="15" height="17" alt="" /><?php echo I18n::_('Clone'); ?></button>
 					<button id="rawtextbutton" class="hidden"><img src="img/icon_raw.png" width="15" height="15" alt="" /><?php echo I18n::_('Raw text'); ?></button>
+					<button id="downloadtextbutton" class="hidden"><?php echo I18n::_('Save paste'), PHP_EOL; ?></button>
 					<button id="emaillink" class="hidden"><img src="img/icon_email.png" width="15" height="15" alt="" /><?php echo I18n::_('Email'); ?></button>
 <?php
 if ($QRCODE):

+ 591 - 1
tst/Bootstrap.php

@@ -1,5 +1,11 @@
 <?php
 
+use Google\Cloud\Core\Exception\BadRequestException;
+use Google\Cloud\Core\Exception\NotFoundException;
+use Google\Cloud\Storage\Bucket;
+use Google\Cloud\Storage\Connection\ConnectionInterface;
+use Google\Cloud\Storage\StorageClient;
+use Google\Cloud\Storage\StorageObject;
 use PrivateBin\Persistence\ServerSalt;
 
 error_reporting(E_ALL | E_STRICT);
@@ -21,6 +27,586 @@ if (!defined('CONF_SAMPLE')) {
 require PATH . 'vendor/autoload.php';
 Helper::updateSubresourceIntegrity();
 
+/**
+ * Class StorageClientStub provides a limited stub for performing the unit test
+ */
+class StorageClientStub extends StorageClient
+{
+    private $_config     = null;
+    private $_connection = null;
+    private $_buckets    = array();
+
+    public function __construct(array $config = array())
+    {
+        $this->_config     = $config;
+        $this->_connection =  new ConnectionInterfaceStub();
+    }
+
+    public function bucket($name, $userProject = false)
+    {
+        if (!key_exists($name, $this->_buckets)) {
+            $b                     = new BucketStub($this->_connection, $name, array(), $this);
+            $this->_buckets[$name] = $b;
+        }
+        return $this->_buckets[$name];
+    }
+
+    /**
+     * @throws \Google\Cloud\Core\Exception\NotFoundException
+     */
+    public function deleteBucket($name)
+    {
+        if (key_exists($name, $this->_buckets)) {
+            unset($this->_buckets[$name]);
+        } else {
+            throw new NotFoundException();
+        }
+    }
+
+    public function buckets(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function registerStreamWrapper($protocol = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function unregisterStreamWrapper($protocol = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUrlUploader($uri, $data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function timestamp(\DateTimeInterface $timestamp, $nanoSeconds = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getServiceAccount(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function hmacKeys(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function hmacKey($accessId, $projectId = null, array $metadata = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function createHmacKey($serviceAccountEmail, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function createBucket($name, array $options = array())
+    {
+        if (key_exists($name, $this->_buckets)) {
+            throw new BadRequestException('already exists');
+        }
+        $b                     = new BucketStub($this->_connection, $name, array(), $this);
+        $this->_buckets[$name] = $b;
+        return $b;
+    }
+}
+
+/**
+ * Class BucketStub stubs a GCS bucket.
+ */
+class BucketStub extends Bucket
+{
+    public $_objects;
+    private $_name;
+    private $_info;
+    private $_connection;
+    private $_client;
+
+    public function __construct(ConnectionInterface $connection, $name, array $info = array(), $client = null)
+    {
+        $this->_name       = $name;
+        $this->_info       = $info;
+        $this->_connection = $connection;
+        $this->_objects    = array();
+        $this->_client     = $client;
+    }
+
+    public function acl()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function defaultAcl()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function exists()
+    {
+        return true;
+    }
+
+    public function upload($data, array $options = array())
+    {
+        if (!is_string($data) || !key_exists('name', $options)) {
+            throw new BadMethodCallException('not supported by this stub');
+        }
+
+        $name                             = $options['name'];
+        $generation                       = '1';
+        $o                                = new StorageObjectStub($this->_connection, $name, $this, $generation, $options);
+        $this->_objects[$options['name']] = $o;
+        $o->setData($data);
+    }
+
+    public function uploadAsync($data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getResumableUploader($data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getStreamableUploader($data, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function object($name, array $options = array())
+    {
+        if (key_exists($name, $this->_objects)) {
+            return $this->_objects[$name];
+        } else {
+            return new StorageObjectStub($this->_connection, $name, $this, null, $options);
+        }
+    }
+
+    public function objects(array $options = array())
+    {
+        $prefix = key_exists('prefix', $options) ? $options['prefix'] : '';
+
+        return new CallbackFilterIterator(
+                new ArrayIterator($this->_objects),
+                function ($current, $key, $iterator) use ($prefix) {
+                    return substr($key, 0, strlen($prefix)) == $prefix;
+                }
+        );
+    }
+
+    public function createNotification($topic, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function notification($id)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function notifications(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function delete(array $options = array())
+    {
+        $this->_client->deleteBucket($this->_name);
+    }
+
+    public function update(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function compose(array $sourceObjects, $name, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function info(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function reload(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function name()
+    {
+        return $this->_name;
+    }
+
+    public static function lifecycle(array $lifecycle = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function currentLifecycle(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function isWritable($file = null)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function iam()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function lockRetentionPolicy(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUrl($expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function generateSignedPostPolicyV4($objectName, $expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+}
+
+/**
+ * Class StorageObjectStub stubs a GCS storage object.
+ */
+class StorageObjectStub extends StorageObject
+{
+    private $_name;
+    private $_data;
+    private $_info;
+    private $_bucket;
+    private $_generation;
+    private $_exists = false;
+    private $_connection;
+
+    public function __construct(ConnectionInterface $connection, $name, $bucket, $generation = null, array $info = array(), $encryptionKey = null, $encryptionKeySHA256 = null)
+    {
+        $this->_name                            = $name;
+        $this->_bucket                          = $bucket;
+        $this->_generation                      = $generation;
+        $this->_info                            = $info;
+        $this->_connection                      = $connection;
+        $timeCreated                            = new Datetime();
+        $this->_info['metadata']['timeCreated'] = $timeCreated->format('Y-m-d\TH:i:s.u\Z');
+    }
+
+    public function acl()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function exists(array $options = array())
+    {
+        return key_exists($this->_name, $this->_bucket->_objects);
+    }
+
+    /**
+     * @throws NotFoundException
+     */
+    public function delete(array $options = array())
+    {
+        if (key_exists($this->_name, $this->_bucket->_objects)) {
+            unset($this->_bucket->_objects[$this->_name]);
+        } else {
+            throw new NotFoundException('key ' . $this->_name . ' not found.');
+        }
+    }
+
+    /**
+     * @throws NotFoundException
+     */
+    public function update(array $metadata, array $options = array())
+    {
+        if (!$this->_exists) {
+            throw new NotFoundException('key ' . $this->_name . ' not found.');
+        }
+        $this->_info = $metadata;
+    }
+
+    public function copy($destination, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function rewrite($destination, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function rename($name, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    /**
+     * @throws NotFoundException
+     */
+    public function downloadAsString(array $options = array())
+    {
+        if (!$this->_exists) {
+            throw new NotFoundException('key ' . $this->_name . ' not found.');
+        }
+        return $this->_data;
+    }
+
+    public function downloadToFile($path, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function downloadAsStream(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function downloadAsStreamAsync(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUrl($expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function signedUploadUrl($expires, array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function beginSignedUploadSession(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function info(array $options = array())
+    {
+        return key_exists('metadata',$this->_info) ? $this->_info['metadata'] : array();
+    }
+
+    public function reload(array $options = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function name()
+    {
+        return $this->_name;
+    }
+
+    public function identity()
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function gcsUri()
+    {
+        return sprintf(
+            'gs://%s/%s',
+            $this->_bucket->name(),
+            $this->_name
+        );
+    }
+
+    public function setData($data)
+    {
+        $this->_data   = $data;
+        $this->_exists = true;
+    }
+}
+
+/**
+ * Class ConnectionInterfaceStub required for the stubs.
+ */
+class ConnectionInterfaceStub implements ConnectionInterface
+{
+    public function deleteAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function patchAcl(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listBuckets(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getBucketIamPolicy(array $args)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function setBucketIamPolicy(array $args)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function testBucketIamPermissions(array $args)
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function patchBucket(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function copyObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function rewriteObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function composeObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listObjects(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function patchObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function downloadObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertObject(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getNotification(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteNotification(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function insertNotification(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listNotifications(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getServiceAccount(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function lockRetentionPolicy(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function createHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function deleteHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function getHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function updateHmacKey(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+
+    public function listHmacKeys(array $args = array())
+    {
+        throw new BadMethodCallException('not supported by this stub');
+    }
+}
+
+/**
+ * Class Helper provides unit tests pastes and comments of various formats
+ */
 class Helper
 {
     /**
@@ -155,7 +741,11 @@ class Helper
     public static function getPastePost($version = 2, array $meta = array())
     {
         $example         = self::getPaste($version, $meta);
-        $example['meta'] = array('expire' => $example['meta']['expire']);
+        if ($version == 2) {
+            $example['meta'] = array('expire' => $example['meta']['expire']);
+        } else {
+            unset($example['meta']['postdate']);
+        }
         return $example;
     }
 

+ 0 - 65
tst/ConfigurationTest.php

@@ -18,8 +18,6 @@ class ConfigurationTest extends TestCase
         $this->_minimalConfig                   = '[main]' . PHP_EOL . '[model]' . PHP_EOL . '[model_options]';
         $this->_options                         = Configuration::getDefaults();
         $this->_options['model_options']['dir'] = PATH . $this->_options['model_options']['dir'];
-        $this->_options['traffic']['dir']       = PATH . $this->_options['traffic']['dir'];
-        $this->_options['purge']['dir']         = PATH . $this->_options['purge']['dir'];
         $this->_path                            = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_cfg';
         if (!is_dir($this->_path)) {
             mkdir($this->_path);
@@ -142,44 +140,6 @@ class ConfigurationTest extends TestCase
         $this->assertEquals('Database', $conf->getKey('class', 'model'), 'old db class gets renamed');
     }
 
-    public function testHandleConfigFileRename()
-    {
-        $options  = $this->_options;
-        Helper::createIniFile(PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini.sample', $options);
-
-        $options['main']['opendiscussion'] = true;
-        $options['main']['fileupload']     = true;
-        $options['main']['template']       = 'darkstrap';
-        Helper::createIniFile(PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini', $options);
-
-        $conf = new Configuration;
-        $this->assertFileExists(CONF, 'old configuration file gets converted');
-        $this->assertFileDoesNotExist(PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini', 'old configuration file gets removed');
-        $this->assertFileDoesNotExist(PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini.sample', 'old configuration sample file gets removed');
-        $this->assertTrue(
-            $conf->getKey('opendiscussion') &&
-            $conf->getKey('fileupload') &&
-            $conf->getKey('template') === 'darkstrap',
-            'configuration values get converted'
-        );
-    }
-
-    public function testRenameIniSample()
-    {
-        $iniSample = PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini.sample';
-
-        Helper::createIniFile(PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini', $this->_options);
-        if (is_file(CONF)) {
-            unlink(CONF);
-        }
-        rename(CONF_SAMPLE, $iniSample);
-        new Configuration;
-        $this->assertFileDoesNotExist($iniSample, 'old sample file gets removed');
-        $this->assertFileExists(CONF_SAMPLE, 'new sample file gets created');
-        $this->assertFileExists(CONF, 'old configuration file gets converted');
-        $this->assertFileDoesNotExist(PATH . 'cfg' . DIRECTORY_SEPARATOR . 'conf.ini', 'old configuration file gets removed');
-    }
-
     public function testConfigPath()
     {
         // setup
@@ -199,29 +159,4 @@ class ConfigurationTest extends TestCase
         }
         putenv('CONFIG_PATH');
     }
-
-    public function testConfigPathIni()
-    {
-        // setup
-        $configFile              = $this->_path . DIRECTORY_SEPARATOR . 'conf.ini';
-        $configMigrated          = $this->_path . DIRECTORY_SEPARATOR . 'conf.php';
-        $options                 = $this->_options;
-        $options['main']['name'] = 'OtherBin';
-        Helper::createIniFile($configFile, $options);
-        $this->assertFileDoesNotExist(CONF, 'configuration in the default location is non existing');
-
-        // test
-        putenv('CONFIG_PATH=' . $this->_path);
-        $conf = new Configuration;
-        $this->assertEquals('OtherBin', $conf->getKey('name'), 'changing config path is supported for ini files as well');
-        $this->assertFileExists($configMigrated, 'old configuration file gets converted');
-        $this->assertFileDoesNotExist($configFile, 'old configuration file gets removed');
-        $this->assertFileDoesNotExist(CONF, 'configuration is not created in the default location');
-
-        // cleanup environment
-        if (is_file($configFile)) {
-            unlink($configFile);
-        }
-        putenv('CONFIG_PATH');
-    }
 }

+ 0 - 4
tst/ConfigurationTestGenerator.php

@@ -429,8 +429,6 @@ class ConfigurationCombinationsTest extends TestCase
         Helper::confBackup();
         $this->_path  = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
         $this->_model = Filesystem::getInstance(array('dir' => $this->_path));
-        ServerSalt::setPath($this->_path);
-        TrafficLimiter::setPath($this->_path);
         $this->reset();
     }
 
@@ -450,8 +448,6 @@ class ConfigurationCombinationsTest extends TestCase
         if ($this->_model->exists(Helper::getPasteId()))
             $this->_model->delete(Helper::getPasteId());
         $configuration['model_options']['dir'] = $this->_path;
-        $configuration['traffic']['dir']       = $this->_path;
-        $configuration['purge']['dir']         = $this->_path;
         Helper::createIniFile(CONF, $configuration);
     }
 

+ 30 - 24
tst/ControllerTest.php

@@ -18,6 +18,8 @@ class ControllerTest extends TestCase
         /* Setup Routine */
         $this->_path  = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
         $this->_data  = Filesystem::getInstance(array('dir' => $this->_path));
+        ServerSalt::setStore($this->_data);
+        TrafficLimiter::setStore($this->_data);
         $this->reset();
     }
 
@@ -38,11 +40,8 @@ class ControllerTest extends TestCase
             $this->_data->delete(Helper::getPasteId());
         }
         $options                         = parse_ini_file(CONF_SAMPLE, true);
-        $options['purge']['dir']         = $this->_path;
-        $options['traffic']['dir']       = $this->_path;
         $options['model_options']['dir'] = $this->_path;
         Helper::createIniFile(CONF, $options);
-        ServerSalt::setPath($this->_path);
     }
 
     /**
@@ -50,6 +49,8 @@ class ControllerTest extends TestCase
      */
     public function testView()
     {
+        $_SERVER['QUERY_STRING']          = Helper::getPasteId();
+        $_GET[Helper::getPasteId()]       = '';
         ob_start();
         new Controller;
         $content = ob_get_contents();
@@ -129,27 +130,9 @@ class ControllerTest extends TestCase
     }
 
     /**
-     * @runInSeparateProcess
+     * @expectedException Exception
+     * @expectedExceptionCode 2
      */
-    public function testHtaccess()
-    {
-        $htaccess = $this->_path . DIRECTORY_SEPARATOR . '.htaccess';
-        @unlink($htaccess);
-
-        $paste = Helper::getPasteJson();
-        $file  = tempnam(sys_get_temp_dir(), 'FOO');
-        file_put_contents($file, $paste);
-        Request::setInputStream($file);
-        $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
-        $_SERVER['REQUEST_METHOD']        = 'POST';
-        $_SERVER['REMOTE_ADDR']           = '::1';
-        ob_start();
-        new Controller;
-        ob_end_clean();
-
-        $this->assertFileExists($htaccess, 'htaccess recreated');
-    }
-
     public function testConf()
     {
         file_put_contents(CONF, '');
@@ -492,6 +475,29 @@ class ControllerTest extends TestCase
         $this->assertFalse($this->_data->exists(Helper::getPasteId()), 'paste exists after posting data');
     }
 
+    /**
+     * @runInSeparateProcess
+     */
+    public function testCreateInvalidFormat()
+    {
+        $options                     = parse_ini_file(CONF, true);
+        $options['traffic']['limit'] = 0;
+        Helper::createIniFile(CONF, $options);
+        $file = tempnam(sys_get_temp_dir(), 'FOO');
+        file_put_contents($file, Helper::getPasteJson(1));
+        Request::setInputStream($file);
+        $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
+        $_SERVER['REQUEST_METHOD']        = 'POST';
+        $_SERVER['REMOTE_ADDR']           = '::1';
+        ob_start();
+        new Controller;
+        $content = ob_get_contents();
+        ob_end_clean();
+        $response = json_decode($content, true);
+        $this->assertEquals(1, $response['status'], 'outputs error status');
+        $this->assertFalse($this->_data->exists(Helper::getPasteId()), 'paste exists after posting data');
+    }
+
     /**
      * @runInSeparateProcess
      */
@@ -540,7 +546,7 @@ class ControllerTest extends TestCase
         ob_end_clean();
         $response = json_decode($content, true);
         $this->assertEquals(1, $response['status'], 'outputs error status');
-        $this->assertFalse($this->_data->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'paste exists after posting data');
+        $this->assertFalse($this->_data->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment exists after posting data');
     }
 
     /**

+ 4 - 0
tst/ControllerWithDbTest.php

@@ -1,6 +1,8 @@
 <?php
 
 use PrivateBin\Data\Database;
+use PrivateBin\Persistence\ServerSalt;
+use PrivateBin\Persistence\TrafficLimiter;
 
 require_once 'ControllerTest.php';
 
@@ -24,6 +26,8 @@ class ControllerWithDbTest extends ControllerTest
         }
         $this->_options['dsn'] = 'sqlite:' . $this->_path . DIRECTORY_SEPARATOR . 'tst.sq3';
         $this->_data           = Database::getInstance($this->_options);
+        ServerSalt::setStore($this->_data);
+        TrafficLimiter::setStore($this->_data);
         $this->reset();
     }
 

+ 59 - 0
tst/ControllerWithGcsTest.php

@@ -0,0 +1,59 @@
+<?php
+
+use Google\Auth\HttpHandler\HttpHandlerFactory;
+use GuzzleHttp\Client;
+use PrivateBin\Data\GoogleCloudStorage;
+use PrivateBin\Persistence\ServerSalt;
+use PrivateBin\Persistence\TrafficLimiter;
+
+require_once 'ControllerTest.php';
+
+class ControllerWithGcsTest extends ControllerTest
+{
+    private static $_client;
+    private static $_bucket;
+    private $_options = array();
+
+    public static function setUpBeforeClass()
+    {
+        $httpClient = new Client(array('debug'=>false));
+        $handler    = HttpHandlerFactory::build($httpClient);
+
+        $name     = 'pb-';
+        $alphabet = 'abcdefghijklmnopqrstuvwxyz';
+        for ($i = 0; $i < 29; ++$i) {
+            $name .= $alphabet[rand(0, strlen($alphabet) - 1)];
+        }
+        self::$_client = new StorageClientStub(array());
+        self::$_bucket = self::$_client->createBucket($name);
+    }
+
+    public function setUp()
+    {
+        /* Setup Routine */
+        $this->_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
+        if (!is_dir($this->_path)) {
+            mkdir($this->_path);
+        }
+        $this->_options = array(
+            'bucket' => self::$_bucket->name(),
+            'prefix' => 'pastes',
+        );
+        $this->_data    = GoogleCloudStorage::getInstance($this->_options);
+        ServerSalt::setStore($this->_data);
+        TrafficLimiter::setStore($this->_data);
+        $this->reset();
+    }
+
+    public function reset()
+    {
+        parent::reset();
+        // but then inject a db config
+        $options          = parse_ini_file(CONF, true);
+        $options['model'] = array(
+            'class' => 'GoogleCloudStorage',
+        );
+        $options['model_options'] = $this->_options;
+        Helper::createIniFile(CONF, $options);
+    }
+}

+ 57 - 0
tst/Data/DatabaseTest.php

@@ -3,6 +3,8 @@
 use PHPUnit\Framework\TestCase;
 use PrivateBin\Controller;
 use PrivateBin\Data\Database;
+use PrivateBin\Data\Filesystem;
+use PrivateBin\Persistence\ServerSalt;
 
 class DatabaseTest extends TestCase
 {
@@ -32,6 +34,19 @@ class DatabaseTest extends TestCase
         }
     }
 
+    public function testSaltMigration()
+    {
+        ServerSalt::setStore(Filesystem::getInstance(array('dir' => 'data')));
+        $salt = ServerSalt::get();
+        $file = 'data' . DIRECTORY_SEPARATOR . 'salt.php';
+        $this->assertFileExists($file, 'ServerSalt got initialized and stored on disk');
+        $this->assertNotEquals($salt, '');
+        ServerSalt::setStore($this->_model);
+        ServerSalt::get();
+        $this->assertFileNotExists($file, 'legacy ServerSalt got removed');
+        $this->assertEquals($salt, ServerSalt::get(), 'ServerSalt got preserved & migrated');
+    }
+
     public function testDatabaseBasedDataStoreWorks()
     {
         $this->_model->delete(Helper::getPasteId());
@@ -266,6 +281,48 @@ class DatabaseTest extends TestCase
         Helper::rmDir($this->_path);
     }
 
+    public function testCorruptMeta()
+    {
+        mkdir($this->_path);
+        $path = $this->_path . DIRECTORY_SEPARATOR . 'meta-test.sq3';
+        if (is_file($path)) {
+            unlink($path);
+        }
+        $this->_options['dsn'] = 'sqlite:' . $path;
+        $this->_options['tbl'] = 'baz_';
+        $model                 = Database::getInstance($this->_options);
+        $paste                 = Helper::getPaste(1, array('expire_date' => 1344803344));
+        unset($paste['meta']['formatter'], $paste['meta']['opendiscussion'], $paste['meta']['salt']);
+        $model->delete(Helper::getPasteId());
+
+        $db = new PDO(
+            $this->_options['dsn'],
+            $this->_options['usr'],
+            $this->_options['pwd'],
+            $this->_options['opt']
+        );
+        $statement = $db->prepare('INSERT INTO baz_paste VALUES(?,?,?,?,?,?,?,?,?)');
+        $statement->execute(
+            array(
+                Helper::getPasteId(),
+                $paste['data'],
+                $paste['meta']['postdate'],
+                $paste['meta']['expire_date'],
+                0,
+                0,
+                '{',
+                null,
+                null,
+            )
+        );
+        $statement->closeCursor();
+
+        $this->assertTrue($model->exists(Helper::getPasteId()), 'paste exists after storing it');
+        $this->assertEquals($paste, $model->read(Helper::getPasteId()));
+
+        Helper::rmDir($this->_path);
+    }
+
     public function testTableUpgrade()
     {
         mkdir($this->_path);

+ 1 - 0
tst/Data/FilesystemTest.php

@@ -118,6 +118,7 @@ class FilesystemTest extends TestCase
         $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does not yet exist');
         $this->assertFalse($this->_model->create(Helper::getPasteId(), $paste), 'unable to store broken paste');
         $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does still not exist');
+        $this->assertFalse($this->_model->setValue('foo', 'non existing namespace'), 'rejects setting value in non existing namespace');
     }
 
     public function testCommentErrorDetection()

+ 182 - 0
tst/Data/GoogleCloudStorageTest.php

@@ -0,0 +1,182 @@
+<?php
+
+use Google\Auth\HttpHandler\HttpHandlerFactory;
+use GuzzleHttp\Client;
+use PrivateBin\Data\GoogleCloudStorage;
+
+class GoogleCloudStorageTest extends PHPUnit_Framework_TestCase
+{
+    private static $_client;
+    private static $_bucket;
+
+    public static function setUpBeforeClass()
+    {
+        $httpClient = new Client(array('debug'=>false));
+        $handler    = HttpHandlerFactory::build($httpClient);
+
+        $name     = 'pb-';
+        $alphabet = 'abcdefghijklmnopqrstuvwxyz';
+        for ($i = 0; $i < 29; ++$i) {
+            $name .= $alphabet[rand(0, strlen($alphabet) - 1)];
+        }
+        self::$_client = new StorageClientStub(array());
+        self::$_bucket = self::$_client->createBucket($name);
+    }
+
+    public function setUp()
+    {
+        ini_set('error_log', stream_get_meta_data(tmpfile())['uri']);
+        $this->_model = GoogleCloudStorage::getInstance(array(
+            'bucket' => self::$_bucket->name(),
+            'prefix' => 'pastes',
+        ));
+    }
+
+    public function tearDown()
+    {
+        foreach (self::$_bucket->objects() as $object) {
+            $object->delete();
+        }
+    }
+
+    public static function tearDownAfterClass()
+    {
+        self::$_bucket->delete();
+    }
+
+    public function testFileBasedDataStoreWorks()
+    {
+        $this->_model->delete(Helper::getPasteId());
+
+        // storing pastes
+        $paste = Helper::getPaste(2, array('expire_date' => 1344803344));
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does not yet exist');
+        $this->assertTrue($this->_model->create(Helper::getPasteId(), $paste), 'store new paste');
+        $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists after storing it');
+        $this->assertFalse($this->_model->create(Helper::getPasteId(), $paste), 'unable to store the same paste twice');
+        $this->assertEquals($paste, $this->_model->read(Helper::getPasteId()));
+
+        // storing comments
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does not yet exist');
+        $this->assertTrue($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), Helper::getComment()), 'store comment');
+        $this->assertTrue($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment exists after storing it');
+        $this->assertFalse($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), Helper::getComment()), 'unable to store the same comment twice');
+        $comment             = Helper::getComment();
+        $comment['id']       = Helper::getCommentId();
+        $comment['parentid'] = Helper::getPasteId();
+        $this->assertEquals(
+            array($comment['meta']['created'] => $comment),
+            $this->_model->readComments(Helper::getPasteId())
+        );
+
+        // deleting pastes
+        $this->_model->delete(Helper::getPasteId());
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste successfully deleted');
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment was deleted with paste');
+        $this->assertFalse($this->_model->read(Helper::getPasteId()), 'paste can no longer be found');
+    }
+
+    /**
+     * pastes a-g are expired and should get deleted, x never expires and y-z expire in an hour
+     */
+    public function testPurge()
+    {
+        $expired = Helper::getPaste(2, array('expire_date' => 1344803344));
+        $paste   = Helper::getPaste(2, array('expire_date' => time() + 3600));
+        $keys    = array('a', 'b', 'c', 'd', 'e', 'f', 'g', 'x', 'y', 'z');
+        $ids     = array();
+        foreach ($keys as $key) {
+            $ids[$key] = hash('fnv164', $key);
+            $this->assertFalse($this->_model->exists($ids[$key]), "paste $key does not yet exist");
+            if (in_array($key, array('x', 'y', 'z'))) {
+                $this->assertTrue($this->_model->create($ids[$key], $paste), "store $key paste");
+            } elseif ($key === 'x') {
+                $this->assertTrue($this->_model->create($ids[$key], Helper::getPaste()), "store $key paste");
+            } else {
+                $this->assertTrue($this->_model->create($ids[$key], $expired), "store $key paste");
+            }
+            $this->assertTrue($this->_model->exists($ids[$key]), "paste $key exists after storing it");
+        }
+        $this->_model->purge(10);
+        foreach ($ids as $key => $id) {
+            if (in_array($key, array('x', 'y', 'z'))) {
+                $this->assertTrue($this->_model->exists($id), "paste $key exists after purge");
+                $this->_model->delete($id);
+            } else {
+                $this->assertFalse($this->_model->exists($id), "paste $key was purged");
+            }
+        }
+    }
+
+    public function testErrorDetection()
+    {
+        $this->_model->delete(Helper::getPasteId());
+        $paste = Helper::getPaste(2, array('expire' => "Invalid UTF-8 sequence: \xB1\x31"));
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does not yet exist');
+        $this->assertFalse($this->_model->create(Helper::getPasteId(), $paste), 'unable to store broken paste');
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does still not exist');
+    }
+
+    public function testCommentErrorDetection()
+    {
+        $this->_model->delete(Helper::getPasteId());
+        $comment = Helper::getComment(1, array('nickname' => "Invalid UTF-8 sequence: \xB1\x31"));
+        $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste does not yet exist');
+        $this->assertTrue($this->_model->create(Helper::getPasteId(), Helper::getPaste()), 'store new paste');
+        $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists after storing it');
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does not yet exist');
+        $this->assertFalse($this->_model->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), $comment), 'unable to store broken comment');
+        $this->assertFalse($this->_model->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment does still not exist');
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function testKeyValueStore()
+    {
+        $salt = bin2hex(random_bytes(256));
+        $this->_model->setValue($salt, 'salt', '');
+        $storedSalt = $this->_model->getValue('salt', '');
+        $this->assertEquals($salt, $storedSalt);
+        $this->_model->purgeValues('salt', time() + 60);
+        $this->assertEquals('', $this->_model->getValue('salt', 'master'));
+
+        $client = hash_hmac('sha512', '127.0.0.1', $salt);
+        $expire = time();
+        $this->_model->setValue(strval($expire), 'traffic_limiter', $client);
+        $storedExpired = $this->_model->getValue('traffic_limiter', $client);
+        $this->assertEquals(strval($expire), $storedExpired);
+
+        $this->_model->purgeValues('traffic_limiter', time() - 60);
+        $this->assertEquals($storedExpired, $this->_model->getValue('traffic_limiter', $client));
+        $this->_model->purgeValues('traffic_limiter', time() + 60);
+        $this->assertEquals('', $this->_model->getValue('traffic_limiter', $client));
+
+        $purgeAt = $expire + (15 * 60);
+        $this->_model->setValue(strval($purgeAt), 'purge_limiter', '');
+        $storedPurgedAt = $this->_model->getValue('purge_limiter', '');
+        $this->assertEquals(strval($purgeAt), $storedPurgedAt);
+        $this->_model->purgeValues('purge_limiter', $purgeAt + 60);
+        $this->assertEquals('', $this->_model->getValue('purge_limiter', ''));
+        $this->assertEquals('', $this->_model->getValue('purge_limiter', 'at'));
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function testKeyValuePurgeTrafficLimiter()
+    {
+        $salt   = bin2hex(random_bytes(256));
+        $client = hash_hmac('sha512', '127.0.0.1', $salt);
+        $expire = time();
+        $this->_model->setValue(strval($expire), 'traffic_limiter', $client);
+        $storedExpired = $this->_model->getValue('traffic_limiter', $client);
+        $this->assertEquals(strval($expire), $storedExpired);
+
+        $this->_model->purgeValues('traffic_limiter', time() - 60);
+        $this->assertEquals($storedExpired, $this->_model->getValue('traffic_limiter', $client));
+
+        $this->_model->purgeValues('traffic_limiter', time() + 60);
+        $this->assertEquals('', $this->_model->getValue('traffic_limiter', $client));
+    }
+}

+ 1 - 3
tst/JsonApiTest.php

@@ -17,7 +17,7 @@ class JsonApiTest extends TestCase
         /* Setup Routine */
         $this->_path  = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
         $this->_model = Filesystem::getInstance(array('dir' => $this->_path));
-        ServerSalt::setPath($this->_path);
+        ServerSalt::setStore($this->_model);
 
         $_POST   = array();
         $_GET    = array();
@@ -26,8 +26,6 @@ class JsonApiTest extends TestCase
             $this->_model->delete(Helper::getPasteId());
         }
         $options                         = parse_ini_file(CONF_SAMPLE, true);
-        $options['purge']['dir']         = $this->_path;
-        $options['traffic']['dir']       = $this->_path;
         $options['model_options']['dir'] = $this->_path;
         Helper::confBackup();
         Helper::createIniFile(CONF, $options);

+ 164 - 1
tst/ModelTest.php

@@ -26,7 +26,6 @@ class ModelTest extends TestCase
         if (!is_dir($this->_path)) {
             mkdir($this->_path);
         }
-        ServerSalt::setPath($this->_path);
         $options                   = parse_ini_file(CONF_SAMPLE, true);
         $options['purge']['limit'] = 0;
         $options['model']          = array(
@@ -40,6 +39,7 @@ class ModelTest extends TestCase
         );
         Helper::confBackup();
         Helper::createIniFile(CONF, $options);
+        ServerSalt::setStore(Database::getInstance($options['model_options']));
         $this->_conf            = new Configuration;
         $this->_model           = new Model($this->_conf);
         $_SERVER['REMOTE_ADDR'] = '::1';
@@ -103,6 +103,58 @@ class ModelTest extends TestCase
         $this->assertEquals(array(), $paste->getComments(), 'comment was deleted with paste');
     }
 
+    public function testPasteV1()
+    {
+        $pasteData = Helper::getPaste(1);
+        unset($pasteData['meta']['formatter']);
+
+        $path = $this->_path . DIRECTORY_SEPARATOR . 'v1-test.sq3';
+        if (is_file($path)) {
+            unlink($path);
+        }
+        $options                   = parse_ini_file(CONF_SAMPLE, true);
+        $options['purge']['limit'] = 0;
+        $options['model']          = array(
+            'class' => 'Database',
+        );
+        $options['model_options'] = array(
+            'dsn' => 'sqlite:' . $path,
+            'usr' => null,
+            'pwd' => null,
+            'opt' => array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION),
+        );
+        Helper::createIniFile(CONF, $options);
+        $model = new Model(new Configuration);
+        $model->getPaste('0000000000000000')->exists(); // triggers database table creation
+        $model->getPaste(Helper::getPasteId())->delete(); // deletes the cache
+
+        $db = new PDO(
+            $options['model_options']['dsn'],
+            $options['model_options']['usr'],
+            $options['model_options']['pwd'],
+            $options['model_options']['opt']
+        );
+        $statement = $db->prepare('INSERT INTO paste VALUES(?,?,?,?,?,?,?,?,?)');
+        $statement->execute(
+            array(
+                Helper::getPasteId(),
+                $pasteData['data'],
+                $pasteData['meta']['postdate'],
+                0,
+                0,
+                0,
+                json_encode($pasteData['meta']),
+                null,
+                null,
+            )
+        );
+        $statement->closeCursor();
+
+        $paste = $model->getPaste(Helper::getPasteId());
+        $this->assertNotEmpty($paste->getDeleteToken(), 'excercise the condition to load the data from storage');
+        $this->assertEquals('plaintext', $paste->get()['meta']['formatter'], 'paste got created with default formatter');
+    }
+
     public function testCommentDefaults()
     {
         $comment = new Comment(
@@ -132,6 +184,101 @@ class ModelTest extends TestCase
         $paste->store();
     }
 
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 76
+     */
+    public function testStoreFail()
+    {
+        $path = $this->_path . DIRECTORY_SEPARATOR . 'model-store-test.sq3';
+        if (is_file($path)) {
+            unlink($path);
+        }
+        $options                   = parse_ini_file(CONF_SAMPLE, true);
+        $options['purge']['limit'] = 0;
+        $options['model']          = array(
+            'class' => 'Database',
+        );
+        $options['model_options'] = array(
+            'dsn' => 'sqlite:' . $path,
+            'usr' => null,
+            'pwd' => null,
+            'opt' => array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION),
+        );
+        Helper::createIniFile(CONF, $options);
+        $model = new Model(new Configuration);
+
+        $pasteData   = Helper::getPastePost();
+        $model->getPaste(Helper::getPasteId())->delete();
+        $model->getPaste(Helper::getPasteId())->exists();
+
+        $db = new PDO(
+            $options['model_options']['dsn'],
+            $options['model_options']['usr'],
+            $options['model_options']['pwd'],
+            $options['model_options']['opt']
+        );
+        $statement = $db->prepare('DROP TABLE paste');
+        $statement->execute();
+        $statement->closeCursor();
+
+        $paste = $model->getPaste();
+        $paste->setData($pasteData);
+        $paste->store();
+    }
+
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 70
+     */
+    public function testCommentStoreFail()
+    {
+        $path = $this->_path . DIRECTORY_SEPARATOR . 'model-test.sq3';
+        if (is_file($path)) {
+            unlink($path);
+        }
+        $options                   = parse_ini_file(CONF_SAMPLE, true);
+        $options['purge']['limit'] = 0;
+        $options['model']          = array(
+            'class' => 'Database',
+        );
+        $options['model_options'] = array(
+            'dsn' => 'sqlite:' . $path,
+            'usr' => null,
+            'pwd' => null,
+            'opt' => array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION),
+        );
+        Helper::createIniFile(CONF, $options);
+        $model = new Model(new Configuration);
+
+        $pasteData   = Helper::getPastePost();
+        $commentData = Helper::getCommentPost();
+        $model->getPaste(Helper::getPasteId())->delete();
+
+        $paste = $model->getPaste();
+        $paste->setData($pasteData);
+        $paste->store();
+        $paste->exists();
+
+        $db = new PDO(
+            $options['model_options']['dsn'],
+            $options['model_options']['usr'],
+            $options['model_options']['pwd'],
+            $options['model_options']['opt']
+        );
+        $statement = $db->prepare('DROP TABLE comment');
+        $statement->execute();
+        $statement->closeCursor();
+
+        $comment = $paste->getComment(Helper::getPasteId());
+        $comment->setData($commentData);
+        $comment->store();
+    }
+
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 69
+     */
     public function testCommentDuplicate()
     {
         $pasteData   = Helper::getPastePost();
@@ -190,6 +337,22 @@ class ModelTest extends TestCase
         $paste->get();
     }
 
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 75
+     */
+    public function testInvalidPasteFormat()
+    {
+        $pasteData             = Helper::getPastePost();
+        $pasteData['adata'][1] = 'format does not exist';
+        $paste                 = $this->_model->getPaste();
+        $paste->setData($pasteData);
+    }
+
+    /**
+     * @expectedException Exception
+     * @expectedExceptionCode 60
+     */
     public function testInvalidPasteId()
     {
         $this->expectException(Exception::class);

+ 4 - 1
tst/Persistence/PurgeLimiterTest.php

@@ -1,6 +1,7 @@
 <?php
 
 use PHPUnit\Framework\TestCase;
+use PrivateBin\Data\Filesystem;
 use PrivateBin\Persistence\PurgeLimiter;
 
 class PurgeLimiterTest extends TestCase
@@ -14,7 +15,9 @@ class PurgeLimiterTest extends TestCase
         if (!is_dir($this->_path)) {
             mkdir($this->_path);
         }
-        PurgeLimiter::setPath($this->_path);
+        PurgeLimiter::setStore(
+            Filesystem::getInstance(array('dir' => $this->_path))
+        );
     }
 
     public function tearDown(): void

+ 36 - 20
tst/Persistence/ServerSaltTest.php

@@ -1,6 +1,7 @@
 <?php
 
 use PHPUnit\Framework\TestCase;
+use PrivateBin\Data\Filesystem;
 use PrivateBin\Persistence\ServerSalt;
 
 class ServerSaltTest extends TestCase
@@ -20,7 +21,9 @@ class ServerSaltTest extends TestCase
         if (!is_dir($this->_path)) {
             mkdir($this->_path);
         }
-        ServerSalt::setPath($this->_path);
+        ServerSalt::setStore(
+            Filesystem::getInstance(array('dir' => $this->_path))
+        );
 
         $this->_otherPath = $this->_path . DIRECTORY_SEPARATOR . 'foo';
 
@@ -41,13 +44,19 @@ class ServerSaltTest extends TestCase
     public function testGeneration()
     {
         // generating new salt
-        ServerSalt::setPath($this->_path);
+        ServerSalt::setStore(
+            Filesystem::getInstance(array('dir' => $this->_path))
+        );
         $salt = ServerSalt::get();
 
         // try setting a different path and resetting it
-        ServerSalt::setPath($this->_otherPath);
+        ServerSalt::setStore(
+            Filesystem::getInstance(array('dir' => $this->_otherPath))
+        );
         $this->assertNotEquals($salt, ServerSalt::get());
-        ServerSalt::setPath($this->_path);
+        ServerSalt::setStore(
+            Filesystem::getInstance(array('dir' => $this->_path))
+        );
         $this->assertEquals($salt, ServerSalt::get());
     }
 
@@ -55,10 +64,11 @@ class ServerSaltTest extends TestCase
     {
         // try setting an invalid path
         chmod($this->_invalidPath, 0000);
-        ServerSalt::setPath($this->_invalidPath);
-        $this->expectException(Exception::class);
-        $this->expectExceptionCode(11);
-        ServerSalt::get();
+        $store = Filesystem::getInstance(array('dir' => $this->_invalidPath));
+        ServerSalt::setStore($store);
+        $salt = ServerSalt::get();
+        ServerSalt::setStore($store);
+        $this->assertNotEquals($salt, ServerSalt::get());
     }
 
     public function testFileRead()
@@ -67,10 +77,11 @@ class ServerSaltTest extends TestCase
         chmod($this->_invalidPath, 0700);
         file_put_contents($this->_invalidFile, '');
         chmod($this->_invalidFile, 0000);
-        ServerSalt::setPath($this->_invalidPath);
-        $this->expectException(Exception::class);
-        $this->expectExceptionCode(20);
-        ServerSalt::get();
+        $store = Filesystem::getInstance(array('dir' => $this->_invalidPath));
+        ServerSalt::setStore($store);
+        $salt = ServerSalt::get();
+        ServerSalt::setStore($store);
+        $this->assertNotEquals($salt, ServerSalt::get());
     }
 
     public function testFileWrite()
@@ -83,19 +94,24 @@ class ServerSaltTest extends TestCase
         }
         file_put_contents($this->_invalidPath . DIRECTORY_SEPARATOR . '.htaccess', '');
         chmod($this->_invalidPath, 0500);
-        ServerSalt::setPath($this->_invalidPath);
-        $this->expectException(Exception::class);
-        $this->expectExceptionCode(13);
-        ServerSalt::get();
+        $store = Filesystem::getInstance(array('dir' => $this->_invalidPath));
+        ServerSalt::setStore($store);
+        $salt = ServerSalt::get();
+        ServerSalt::setStore($store);
+        $this->assertNotEquals($salt, ServerSalt::get());
     }
 
     public function testPermissionShenanigans()
     {
         // try creating an invalid path
         chmod($this->_invalidPath, 0000);
-        ServerSalt::setPath($this->_invalidPath . DIRECTORY_SEPARATOR . 'baz');
-        $this->expectException(Exception::class);
-        $this->expectExceptionCode(10);
-        ServerSalt::get();
+        ServerSalt::setStore(
+            Filesystem::getInstance(array('dir' => $this->_invalidPath . DIRECTORY_SEPARATOR . 'baz'))
+        );
+        $store = Filesystem::getInstance(array('dir' => $this->_invalidPath));
+        ServerSalt::setStore($store);
+        $salt = ServerSalt::get();
+        ServerSalt::setStore($store);
+        $this->assertNotEquals($salt, ServerSalt::get());
     }
 }

+ 29 - 4
tst/Persistence/TrafficLimiterTest.php

@@ -1,6 +1,8 @@
 <?php
 
 use PHPUnit\Framework\TestCase;
+use PrivateBin\Data\Filesystem;
+use PrivateBin\Persistence\ServerSalt;
 use PrivateBin\Persistence\TrafficLimiter;
 
 class TrafficLimiterTest extends TestCase
@@ -11,7 +13,9 @@ class TrafficLimiterTest extends TestCase
     {
         /* Setup Routine */
         $this->_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'trafficlimit';
-        TrafficLimiter::setPath($this->_path);
+        $store       = Filesystem::getInstance(array('dir' => $this->_path));
+        ServerSalt::setStore($store);
+        TrafficLimiter::setStore($store);
     }
 
     public function tearDown(): void
@@ -20,11 +24,17 @@ class TrafficLimiterTest extends TestCase
         Helper::rmDir($this->_path . DIRECTORY_SEPARATOR);
     }
 
+    public function testHtaccess()
+    {
+        $htaccess = $this->_path . DIRECTORY_SEPARATOR . '.htaccess';
+        @unlink($htaccess);
+        $_SERVER['REMOTE_ADDR'] = 'foobar';
+        TrafficLimiter::canPass();
+        $this->assertFileExists($htaccess, 'htaccess recreated');
+    }
+
     public function testTrafficGetsLimited()
     {
-        $this->assertEquals($this->_path, TrafficLimiter::getPath());
-        $file = 'baz';
-        $this->assertEquals($this->_path . DIRECTORY_SEPARATOR . $file, TrafficLimiter::getPath($file));
         TrafficLimiter::setLimit(4);
         $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
         $this->assertTrue(TrafficLimiter::canPass(), 'first request may pass');
@@ -36,5 +46,20 @@ class TrafficLimiterTest extends TestCase
         $this->assertTrue(TrafficLimiter::canPass(), 'fourth request has different ip and may pass');
         $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
         $this->assertFalse(TrafficLimiter::canPass(), 'fifth request is to fast, may not pass');
+
+        // exempted IPs configuration
+        TrafficLimiter::setExemptedIp('1.2.3.4,10.10.10.0/24,2001:1620:2057::/48');
+        $this->assertFalse(TrafficLimiter::canPass(), 'still too fast and not exempted');
+        $_SERVER['REMOTE_ADDR'] = '10.10.10.10';
+        $this->assertTrue(TrafficLimiter::canPass(), 'IPv4 in exempted range');
+        $this->assertTrue(TrafficLimiter::canPass(), 'request is to fast, but IPv4 in exempted range');
+        $_SERVER['REMOTE_ADDR'] = '2001:1620:2057:dead:beef::cafe:babe';
+        $this->assertTrue(TrafficLimiter::canPass(), 'IPv6 in exempted range');
+        $this->assertTrue(TrafficLimiter::canPass(), 'request is to fast, but IPv6 in exempted range');
+        TrafficLimiter::setExemptedIp('127.*,foobar');
+        $this->assertFalse(TrafficLimiter::canPass(), 'request is to fast, invalid range');
+        $_SERVER['REMOTE_ADDR'] = 'foobar';
+        $this->assertTrue(TrafficLimiter::canPass(), 'non-IP address');
+        $this->assertTrue(TrafficLimiter::canPass(), 'request is to fast, but non-IP address matches exempted range');
     }
 }

+ 1 - 0
tst/ViewTest.php

@@ -50,6 +50,7 @@ class ViewTest extends TestCase
         $page->assign('PASSWORD', true);
         $page->assign('FILEUPLOAD', false);
         $page->assign('ZEROBINCOMPATIBILITY', false);
+        $page->assign('INFO', 'example');
         $page->assign('NOTICE', 'example');
         $page->assign('LANGUAGESELECTION', '');
         $page->assign('LANGUAGES', I18n::getLanguageLabels(I18n::getAvailableLanguages()));

+ 2 - 1
tst/Vizhash16x16Test.php

@@ -1,6 +1,7 @@
 <?php
 
 use PHPUnit\Framework\TestCase;
+use PrivateBin\Data\Filesystem;
 use PrivateBin\Persistence\ServerSalt;
 use PrivateBin\Vizhash16x16;
 
@@ -18,7 +19,7 @@ class Vizhash16x16Test extends TestCase
             mkdir($this->_path);
         }
         $this->_file = $this->_path . DIRECTORY_SEPARATOR . 'vizhash.png';
-        ServerSalt::setPath($this->_path);
+        ServerSalt::setStore(Filesystem::getInstance(array('dir' => $this->_path)));
     }
 
     public function tearDown(): void

Неке датотеке нису приказане због велике количине промена