Compare commits
235
Commits
v0.1.0
..
5075374abe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5075374abe | ||
|
|
0008b8ce0c | ||
|
|
1dd8a3b28a | ||
|
|
9a5eaed00b | ||
|
|
ca59c19fa4 | ||
|
|
e059ca1554 | ||
|
|
86eb821780 | ||
|
|
e589428315 | ||
|
|
d18bbb7064 | ||
|
|
cb58a51bab | ||
|
|
98ea7eefa9 | ||
|
|
44cff7e718 | ||
|
|
fa702eb8b1 | ||
|
|
7c7e60375c | ||
|
|
700b8252f0 | ||
|
|
89a9c173ca | ||
|
|
13884ca786 | ||
|
|
7cffd21df9 | ||
|
|
5034f156ee | ||
|
|
6951470bda | ||
|
|
c320fa03c9 | ||
|
|
672288e60b | ||
|
|
5039a76bec | ||
|
|
a49127eff2 | ||
|
|
8636a23f49 | ||
|
|
348f792c73 | ||
|
|
5c6cd40539 | ||
|
|
ec791cc2db | ||
|
|
23cf3151c4 | ||
|
|
f1665f1b31 | ||
|
|
e1056df747 | ||
|
|
5fdb34a059 | ||
|
|
734d656f0c | ||
|
|
8042116a69 | ||
|
|
d77f1faf67 | ||
|
|
2149fcc2e1 | ||
|
|
e430eb57b7 | ||
|
|
1f5e671a43 | ||
|
|
b603b29359 | ||
|
|
2a21251c47 | ||
|
|
179eb17da9 | ||
|
|
bb72e09f8f | ||
|
|
6c1bfaf15e | ||
|
|
9621faa052 | ||
|
|
1a8da8cd7a | ||
|
|
368620eb15 | ||
|
|
5fa728f276 | ||
|
|
80e761f53d | ||
|
|
158c0fe4b6 | ||
|
|
02fcb80b6d | ||
|
|
f947415b23 | ||
|
|
f6c7bfa66b | ||
|
|
0e5cfcd403 | ||
|
|
d402113f19 | ||
|
|
bb7251bd25 | ||
|
|
9692531a40 | ||
|
|
b801d7fb56 | ||
|
|
362a59690f | ||
|
|
0643ca433e | ||
|
|
6064404723 | ||
|
|
aa4d017631 | ||
|
|
dee062dfa5 | ||
|
|
6ef0de6e19 | ||
|
|
28a79b20ea | ||
|
|
3fa2c93e22 | ||
|
|
bead90699d | ||
|
|
d7fb87f19b | ||
|
|
b9153c1d95 | ||
|
|
51685f8667 | ||
|
|
269acf1fa5 | ||
|
|
122c1e14f4 | ||
|
|
9712926cf5 | ||
|
|
5fc5ae8aad | ||
|
|
5dba356264 | ||
|
|
4683b8b542 | ||
|
|
16cad8796f | ||
|
|
317ed2137a | ||
|
|
93fea6ad1c | ||
|
|
0787871f2b | ||
|
|
85dcaea522 | ||
|
|
c4d55cb196 | ||
|
|
5a227e2feb | ||
|
|
7a74ff3e74 | ||
|
|
fc07d8b9d7 | ||
|
|
b30d41b87d | ||
|
|
ca77250cce | ||
|
|
521b6f8011 | ||
|
|
fe75dfc746 | ||
|
|
00aaafeb3c | ||
|
|
d7b51f02b6 | ||
|
|
b0a58d6ca5 | ||
|
|
747eba7064 | ||
|
|
33ccfd373a | ||
|
|
72dbfb1b3c | ||
|
|
d44e4d1587 | ||
|
|
d013f3965f | ||
|
|
22c66243d5 | ||
|
|
98222e762f | ||
|
|
a95ee57000 | ||
|
|
b096c2073c | ||
|
|
4d50e644b9 | ||
|
|
8abf821ce9 | ||
|
|
da9039b606 | ||
|
|
7dc70d62f6 | ||
|
|
0073335875 | ||
|
|
fa2e9b1bb5 | ||
|
|
9f4ed91b8f | ||
|
|
24482abccc | ||
|
|
7cc6a9c706 | ||
|
|
19cb8d1d45 | ||
|
|
4948ef9f1c | ||
|
|
dcf2d66a82 | ||
|
|
821f49993f | ||
|
|
f1b5dd0364 | ||
|
|
8eabeb4839 | ||
|
|
7ed50087a6 | ||
|
|
8104cdba4c | ||
|
|
be3ff81e5c | ||
|
|
1e317d11da | ||
|
|
2ce50a6393 | ||
|
|
70c284b15d | ||
|
|
0ceeabc810 | ||
|
|
8dde83ff68 | ||
|
|
aca99bc6d4 | ||
|
|
073d39ccba | ||
|
|
9ec7d5152e | ||
|
|
a677e627a8 | ||
|
|
7a2fa00f4a | ||
|
|
6b95994c53 | ||
|
|
7fde147500 | ||
|
|
cf8674b898 | ||
|
|
f6e81de2a3 | ||
|
|
e17ea4f8c2 | ||
|
|
5afe81f34c | ||
|
|
097ffb7971 | ||
|
|
815635a973 | ||
|
|
df73d56e32 | ||
|
|
9d89995c91 | ||
|
|
805cc3ff2b | ||
|
|
f37cc932c4 | ||
|
|
65ed0c7043 | ||
|
|
8ecf5338d5 | ||
|
|
69da340224 | ||
|
|
b1af398bea | ||
|
|
15f199b2fe | ||
|
|
9df6224a27 | ||
|
|
c35531e03e | ||
|
|
be2b4fb47f | ||
|
|
064f05536a | ||
|
|
4175ffef3e | ||
|
|
ea9edc211a | ||
|
|
6d467312af | ||
|
|
b21be29daa | ||
|
|
cd70dd0b0e | ||
|
|
5b9be808ed | ||
|
|
19804d47a3 | ||
|
|
ffc10c2b86 | ||
|
|
bf5a79878f | ||
|
|
60200ac14b | ||
|
|
49f7b6e7a4 | ||
|
|
959a83be1e | ||
|
|
5b068faf8f | ||
|
|
b0360af79c | ||
|
|
806798fae6 | ||
|
|
8977c05cda | ||
|
|
389e4d333f | ||
|
|
99a044e8a2 | ||
|
|
3d1d5ee1fd | ||
|
|
ccb22a2d28 | ||
|
|
3d415edbdc | ||
|
|
7ca1b58362 | ||
|
|
8d0825ac09 | ||
|
|
4cb8a3e127 | ||
|
|
91dbf6f344 | ||
|
|
b600cdf51a | ||
|
|
47c9fa6386 | ||
|
|
b7d1119d6d | ||
|
|
44fe3c5772 | ||
|
|
556d0fa2e1 | ||
|
|
bdd5235f2e | ||
|
|
91568c0598 | ||
|
|
94a4a3c2ec | ||
|
|
78374b2d49 | ||
|
|
e08cafe9c8 | ||
|
|
2159982795 | ||
|
|
bb5c4f0186 | ||
|
|
9811191648 | ||
|
|
5a2aa0f96d | ||
|
|
c288fc73a7 | ||
|
|
f6c4342468 | ||
|
|
f611cc3e9e | ||
|
|
594b5f01aa | ||
|
|
03bf327f6b | ||
|
|
a604c5906a | ||
|
|
2c1b2818a8 | ||
|
|
b1187e061f | ||
|
|
4f1e901f04 | ||
|
|
a3349b4a98 | ||
|
|
03600b2ed5 | ||
|
|
cb23707125 | ||
|
|
15ce7ff011 | ||
|
|
238dc96454 | ||
|
|
d5a47b1a31 | ||
|
|
127c12c79a | ||
|
|
2e1ce508fa | ||
|
|
76d7ac12e7 | ||
|
|
129934b645 | ||
|
|
e83303a428 | ||
|
|
acf71f7444 | ||
|
|
9c4689e6de | ||
|
|
ebcf7eec80 | ||
|
|
b3112d2a35 | ||
|
|
6a6f4d1587 | ||
|
|
e31aa44ecb | ||
|
|
5b7198fb3c | ||
|
|
efa8c7145c | ||
|
|
7410b5c9cf | ||
|
|
3facff3d1e | ||
|
|
9d75d2f80d | ||
|
|
6b21f997b5 | ||
|
|
103a7a6af5 | ||
|
|
1f162c4003 | ||
|
|
0bbe65ce05 | ||
|
|
db17bd20b0 | ||
|
|
f542ce0407 | ||
|
|
c32a6e406d | ||
|
|
ec8f75d80b | ||
|
|
efa551c566 | ||
|
|
3e0e64dc4f | ||
|
|
4a467ca999 | ||
|
|
026d9b8c92 | ||
|
|
4d98a92b09 | ||
|
|
603b807c51 | ||
|
|
acafc13d20 | ||
|
|
176ef77737 |
+11
-6
@@ -2,13 +2,13 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: [debian-latest]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -23,10 +23,10 @@ jobs:
|
|||||||
run: go test -v ./...
|
run: go test -v ./...
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
# build & push image only on tags (releases)
|
# build & push image only on tags (releases) and dev branch (dev deploy)
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/dev'
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: [debian-latest]
|
||||||
env:
|
env:
|
||||||
# dind sidecar listens on tcp; job containers reach it via the docker bridge gateway
|
# dind sidecar listens on tcp; job containers reach it via the docker bridge gateway
|
||||||
DOCKER_HOST: tcp://172.17.0.1:2375
|
DOCKER_HOST: tcp://172.17.0.1:2375
|
||||||
@@ -36,6 +36,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz && tar -xzf /tmp/docker.tgz -C /tmp && mv /tmp/docker/docker /usr/local/bin/docker && chmod +x /usr/local/bin/docker
|
||||||
|
|
||||||
- name: Set up Buildx
|
- name: Set up Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -51,8 +55,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
build-args: VERSION=${{ gitea.ref_name }}
|
||||||
tags: |
|
tags: |
|
||||||
git.archfox.org/poslop/palette:${{ gitea.ref_name }}
|
git.archfox.org/poslop/palette:${{ gitea.ref_name }}
|
||||||
git.archfox.org/poslop/palette:latest
|
${{ startsWith(gitea.ref, 'refs/tags/') && 'git.archfox.org/poslop/palette:latest' || '' }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
+7
-4
@@ -1,4 +1,7 @@
|
|||||||
__pycache__/
|
/palette
|
||||||
node_modules/
|
palette.db
|
||||||
*.log
|
palette.db-shm
|
||||||
.DS_Store
|
palette.db-wal
|
||||||
|
admin-key
|
||||||
|
settings.json
|
||||||
|
:memory:.files/
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /palette .
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X palette/internal/web.Version=${VERSION}" -o /palette ./cmd/palette
|
||||||
|
|
||||||
# ---- runtime stage ----
|
# ---- runtime stage ----
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|||||||
@@ -0,0 +1,679 @@
|
|||||||
|
Palette - self-hosted pastebin with paste cans
|
||||||
|
Copyright (C) 2026 poslop
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, version 3 of the License.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -1,112 +1,98 @@
|
|||||||
# Palette
|
# Palette
|
||||||
|
|
||||||
Fast, self-hosted pastebin with paste cans, password lock, expiry, custom URLs, and an API-first design.
|
Palette is a fast, self-hosted pastebin. One Go binary, a SQLite database, and
|
||||||
|
a web UI for sharing text and small files
|
||||||
|
|
||||||
## Quick start
|
> [!NOTE]
|
||||||
|
> <table><tr><td>
|
||||||
|
> <sub>✨ This project was built by AI — <strong>GLM 5.3 Flash</strong> on the <strong>Hermes Agent</strong> harness, with a human in the loop.</sub>
|
||||||
|
> </td></tr></table>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Multiple files in one paste
|
||||||
|
- Password protected pastes
|
||||||
|
- Expir after a specified time
|
||||||
|
- Burn after a number of views
|
||||||
|
- Custom URLs
|
||||||
|
- Syntax highlighting with language auto-detection (go-enry)
|
||||||
|
- Local cookie based submission history
|
||||||
|
- Cookie based settings
|
||||||
|
- Themes!
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## Get Started
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o palette .
|
docker run -p 8080:8080 -v palette-data:/data git.archfox.org/poslop/palette
|
||||||
|
```
|
||||||
|
|
||||||
|
The SQLite database lives in the `/data` volume inside the container.
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
See [docker-compose.yml](docker-compose.yml) for a ready-to-use example with
|
||||||
|
every environment variable documented, including which are required (only the
|
||||||
|
/data volume) and which are optional.
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
Requires Go 1.27+.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o palette ./cmd/palette
|
||||||
./palette
|
./palette
|
||||||
# UI at http://localhost:8080
|
# open http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t palette .
|
|
||||||
docker run -p 8080:8080 -v palette-data:/data palette
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Env var | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PALETTE_ADDR` | `:8080` | Listen address |
|
| `PALETTE_ADDR` | `:8080` | Listen address |
|
||||||
| `PALETTE_DB` | `palette.db` | SQLite database path |
|
| `PALETTE_DB` | `palette.db` | SQLite database path |
|
||||||
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
|
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
|
||||||
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
|
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
|
||||||
|
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
|
||||||
|
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
|
||||||
|
|
||||||
|
An `/admin` page exists for runtime settings, protected by a key set at
|
||||||
|
install (`PALETTE_ADMIN_KEY` env var) and resettable locally — see
|
||||||
|
[API](https://git.archfox.org/poslop/palette/wiki/API) and the [design docs](https://git.archfox.org/poslop/palette/wiki/Home) in the wiki for details.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### Create paste
|
Full REST API: [wiki/API](https://git.archfox.org/poslop/palette/wiki/API). One example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/api/pastes \
|
curl -X POST http://localhost:8080/api/pastes -d '{"content":"hello"}'
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"content": "print(hello)",
|
|
||||||
"title": "my snippet",
|
|
||||||
"language": "python",
|
|
||||||
"expires_in": "168h",
|
|
||||||
"password": "optional",
|
|
||||||
"custom_slug": "optional",
|
|
||||||
"burn_after_read": false,
|
|
||||||
"visibility": "public"
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Response includes `id`, `url`, `raw_url`, `api_url`, and a one-time `deletion_token`.
|
Design docs: [wiki/design](https://git.archfox.org/poslop/palette/wiki/design-attachments-storage) (e2e encryption, attachments & storage, cookie preferences).
|
||||||
|
|
||||||
### Get paste
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/api/pastes/{id}
|
|
||||||
# password-protected pastes:
|
|
||||||
curl "http://localhost:8080/api/pastes/{id}?password=secret"
|
|
||||||
# or via header: X-Paste-Password: secret
|
|
||||||
```
|
|
||||||
|
|
||||||
### Raw content
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/raw/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Soft delete
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:8080/api/pastes/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hard delete (requires deletion token)
|
## License
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Public history
|
Palette is licensed under the GNU Affero General Public License v3.0
|
||||||
```bash
|
(AGPL-3.0-only). See [LICENSE](LICENSE).
|
||||||
curl "http://localhost:8080/api/public?limit=25&offset=0"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create can (bundle of items)
|
The software is provided AS IS, without warranty of any kind, express or
|
||||||
```bash
|
implied, including merchantability and fitness for a particular purpose.
|
||||||
curl -X POST http://localhost:8080/api/pastes/can \
|
In no event shall the authors be liable for any claim, damages, or other
|
||||||
-F "title=My bundle" \
|
liability, including without limitation any security vulnerabilities,
|
||||||
-F "expires_in=48h" \
|
data loss, or legal issues arising from use of the software. You use it
|
||||||
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
|
at your own risk.
|
||||||
-F "files=@screenshot.png" \
|
|
||||||
-F "files=@log.txt"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get can + items
|
If you run a modified version of Palette as a network service, the AGPL
|
||||||
```bash
|
requires you to offer your modified source code to its users.
|
||||||
curl http://localhost:8080/api/cans/{id}
|
|
||||||
curl http://localhost:8080/api/cans/{id}/items/{item_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expiry and deletion
|
|
||||||
|
|
||||||
- Expired pastes are soft-deleted by a background sweeper (runs every minute).
|
|
||||||
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
|
|
||||||
- Deletion tokens allow immediate hard delete.
|
|
||||||
- Burn-after-read pastes are soft-deleted on first read.
|
|
||||||
|
|
||||||
## Web pages
|
|
||||||
|
|
||||||
- `/new` — create a paste
|
|
||||||
- `/history` — public paste history
|
|
||||||
- `/{id}` — view a paste
|
|
||||||
- `/unlock/{id}` — password gate for protected pastes
|
|
||||||
- `/raw/{id}` — raw content with original content type
|
|
||||||
|
|
||||||
## CI
|
|
||||||
|
|
||||||
Gitea Actions workflow at `.gitea/workflows/ci.yml`:
|
|
||||||
- On push to main: `go vet` + `go test`
|
|
||||||
- On tags: build and push Docker image to `git.archfox.org/poslop/palette`
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/base64"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// genDeletionToken returns a 32-char url-safe random token
|
|
||||||
func genDeletionToken() string {
|
|
||||||
b := make([]byte, 24)
|
|
||||||
rand.Read(b)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeBurn marks a paste soft-deleted if burn_after_read is set.
|
|
||||||
// Returns true if this read consumed the paste.
|
|
||||||
func (s *Store) maybeBurn(row *PasteRow) bool {
|
|
||||||
if !row.BurnAfterRead {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s.SoftDelete(row.ID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func deletionTokenEqual(stored, given string) bool {
|
|
||||||
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
|
|
||||||
// DELETE /api/pastes/{id}/redeem?token=...
|
|
||||||
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
token := r.URL.Query().Get("token")
|
|
||||||
if token == "" {
|
|
||||||
writeErr(w, 400, "token required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil || row == nil {
|
|
||||||
writeErr(w, 404, "paste not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.DeletionToken.String == "" || !deletionTokenEqual(row.DeletionToken.String, token) {
|
|
||||||
writeErr(w, 403, "invalid token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// hard delete: pastes table row goes away entirely
|
|
||||||
a.store.db.Exec(`DELETE FROM pastes WHERE id = ?`, row.ID)
|
|
||||||
writeJSON(w, 200, map[string]string{"status": "deleted"})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Command palette is the palette pastebin server entrypoint: flag parsing
|
||||||
|
// and wiring of the store, API, and web packages.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"palette/internal/api"
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// #40: --reset-admin-key regenerates the admin key and exits.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "--reset-admin-key" {
|
||||||
|
api.HandleResetAdminKey(api.EnvOr("PALETTE_DB", "palette.db"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := api.Config{
|
||||||
|
Addr: api.EnvOr("PALETTE_ADDR", ":8080"),
|
||||||
|
DBPath: api.EnvOr("PALETTE_DB", "palette.db"),
|
||||||
|
MaxTextBytes: int64(api.EnvIntOr("PALETTE_MAX_TEXT", 5*1024*1024)),
|
||||||
|
MaxItemBytes: int64(api.EnvIntOr("PALETTE_MAX_ITEM", 25*1024*1024)),
|
||||||
|
}
|
||||||
|
st, err := store.OpenStore(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminKey, err := api.ResolveAdminKey(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ss := api.LoadSettingsStore(cfg.DBPath, cfg)
|
||||||
|
|
||||||
|
st.StartSweeper(time.Minute, ss.Get().CustomSlugReservationDays)
|
||||||
|
|
||||||
|
ui, err := web.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
web.SetDefaultDark(os.Getenv("PALETTE_DEFAULT_DARK"))
|
||||||
|
srv := api.NewServer(st, cfg, ui, ss, adminKey)
|
||||||
|
log.Printf("palette listening on %s", cfg.Addr)
|
||||||
|
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Example docker-compose deployment for Palette.
|
||||||
|
# All environment variables are optional; sensible defaults apply.
|
||||||
|
# The only hard requirement is a volume on /data so the SQLite database
|
||||||
|
# and the auto-generated admin key survive restarts.
|
||||||
|
|
||||||
|
services:
|
||||||
|
palette:
|
||||||
|
image: git.archfox.org/poslop/palette:v0.2.2
|
||||||
|
# image: git.archfox.org/poslop/palette:latest # tracks main, less stable
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # host:container; the app listens on :8080
|
||||||
|
volumes:
|
||||||
|
- palette-data:/data # REQUIRED: SQLite db, admin key, attachments
|
||||||
|
environment:
|
||||||
|
# Address the server binds to inside the container.
|
||||||
|
# Default: ":8080". Only change if you also change the ports mapping.
|
||||||
|
PALETTE_ADDR: ":8080"
|
||||||
|
|
||||||
|
# Path to the SQLite database file.
|
||||||
|
# Default: "/data/palette.db". Keep it on the /data volume.
|
||||||
|
PALETTE_DB: "/data/palette.db"
|
||||||
|
|
||||||
|
# Admin API key for /admin/api/settings (rate limits, size caps, expiry).
|
||||||
|
# Default: random key generated on first start and persisted to
|
||||||
|
# /data/admin-key (mode 0600). Read it with:
|
||||||
|
# docker compose exec palette cat /data/admin-key
|
||||||
|
# Set this only if you want a fixed key (e.g. for automation).
|
||||||
|
# PALETTE_ADMIN_KEY: "change-me"
|
||||||
|
|
||||||
|
# Max size in bytes of a single text paste. Oversized creates get 413.
|
||||||
|
# Default: 5242880 (5 MiB).
|
||||||
|
# PALETTE_MAX_TEXT: "5242880"
|
||||||
|
|
||||||
|
# Max size in bytes of a single can item (file/text inside a can).
|
||||||
|
# Default: 26214400 (25 MiB).
|
||||||
|
# PALETTE_MAX_ITEM: "26214400"
|
||||||
|
|
||||||
|
# HMAC secret for password-unlock cookies. Default: random per start,
|
||||||
|
# which logs out every unlocked browser session on restart. Set a fixed
|
||||||
|
# secret (any random string) to keep unlock sessions across restarts,
|
||||||
|
# or when running multiple replicas that must agree.
|
||||||
|
# PALETTE_UNLOCK_SECRET: "generate-with-openssl-rand-base64-32"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
palette-data:
|
||||||
@@ -4,12 +4,14 @@ go 1.27.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.3.2
|
github.com/go-chi/chi/v5 v5.3.2
|
||||||
|
github.com/go-enry/go-enry/v2 v2.9.6
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.39.0
|
||||||
modernc.org/sqlite v1.58.0
|
modernc.org/sqlite v1.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-enry/go-oniguruma v1.2.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs=
|
||||||
|
github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
|
||||||
|
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
|
||||||
|
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -12,8 +19,17 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
|||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
@@ -24,6 +40,11 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #40: admin endpoint with an install-time key. The key is read from
|
||||||
|
// PALETTE_ADMIN_KEY when set; otherwise a 32-char random hex key is generated
|
||||||
|
// and persisted to <db-dir>/admin-key (0600) so it survives restarts.
|
||||||
|
|
||||||
|
// Settings holds the runtime-tunable values the admin API exposes. The list
|
||||||
|
// is intentionally small and extensible: add a field + JSON tag, wire it into
|
||||||
|
// the consumer, and it round-trips through GET/POST /admin/api/settings.
|
||||||
|
type Settings struct {
|
||||||
|
RateLimitBurst float64 `json:"rate_limit_burst"`
|
||||||
|
RateLimitPerMinute float64 `json:"rate_limit_per_minute"`
|
||||||
|
MaxContentBytes int64 `json:"max_content_bytes"`
|
||||||
|
DefaultExpiry string `json:"default_expiry"`
|
||||||
|
CustomSlugReservationDays int `json:"custom_slug_reservation_days"`
|
||||||
|
BurnViewerWindowMinutes int `json:"burn_viewer_window_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSettings(cfg Config) Settings {
|
||||||
|
return Settings{
|
||||||
|
RateLimitBurst: 5,
|
||||||
|
RateLimitPerMinute: 60, // 1 req/sec refill
|
||||||
|
MaxContentBytes: cfg.MaxTextBytes,
|
||||||
|
DefaultExpiry: "", // no default: pastes are permanent unless expires_in given
|
||||||
|
CustomSlugReservationDays: 30,
|
||||||
|
BurnViewerWindowMinutes: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsStore keeps the current settings in memory (mutex-guarded) and
|
||||||
|
// persists them as JSON to <db-dir>/settings.json.
|
||||||
|
type settingsStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cur Settings
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSettingsStore loads (or initializes) the settings store.
|
||||||
|
func LoadSettingsStore(dbPath string, cfg Config) *settingsStore {
|
||||||
|
p := filepath.Join(filepath.Dir(dbPath), "settings.json")
|
||||||
|
ss := &settingsStore{cur: defaultSettings(cfg), path: p}
|
||||||
|
if b, err := os.ReadFile(p); err == nil {
|
||||||
|
var s Settings
|
||||||
|
if json.Unmarshal(b, &s) == nil {
|
||||||
|
// merge over defaults so newly added fields keep sane values
|
||||||
|
def := defaultSettings(cfg)
|
||||||
|
if s.RateLimitBurst > 0 {
|
||||||
|
def.RateLimitBurst = s.RateLimitBurst
|
||||||
|
}
|
||||||
|
if s.RateLimitPerMinute > 0 {
|
||||||
|
def.RateLimitPerMinute = s.RateLimitPerMinute
|
||||||
|
}
|
||||||
|
if s.MaxContentBytes > 0 {
|
||||||
|
def.MaxContentBytes = s.MaxContentBytes
|
||||||
|
}
|
||||||
|
if s.DefaultExpiry != "" {
|
||||||
|
def.DefaultExpiry = s.DefaultExpiry
|
||||||
|
}
|
||||||
|
if s.CustomSlugReservationDays > 0 {
|
||||||
|
def.CustomSlugReservationDays = s.CustomSlugReservationDays
|
||||||
|
}
|
||||||
|
if s.BurnViewerWindowMinutes > 0 {
|
||||||
|
def.BurnViewerWindowMinutes = s.BurnViewerWindowMinutes
|
||||||
|
}
|
||||||
|
ss.cur = def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ss *settingsStore) get() Settings {
|
||||||
|
ss.mu.RLock()
|
||||||
|
defer ss.mu.RUnlock()
|
||||||
|
return ss.cur
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ss *settingsStore) set(s Settings) error {
|
||||||
|
if s.RateLimitBurst <= 0 || s.RateLimitPerMinute <= 0 || s.MaxContentBytes <= 0 ||
|
||||||
|
s.CustomSlugReservationDays <= 0 || s.BurnViewerWindowMinutes <= 0 {
|
||||||
|
return fmt.Errorf("all numeric settings must be positive")
|
||||||
|
}
|
||||||
|
if s.DefaultExpiry != "" {
|
||||||
|
d, err := time.ParseDuration(s.DefaultExpiry)
|
||||||
|
if err != nil || !store.ValidExpiry(d) {
|
||||||
|
return fmt.Errorf("default_expiry must be a duration between 1 minute and 1 year (or empty)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ss.mu.Lock()
|
||||||
|
defer ss.mu.Unlock()
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
if err := os.WriteFile(ss.path, b, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ss.cur = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetAdminKeyFile deletes the persisted admin key file (if any) and returns
|
||||||
|
// the path so callers can regenerate. Used by --reset-admin-key (#40).
|
||||||
|
// ResetAdminKeyFile deletes the persisted admin key file (if any).
|
||||||
|
func ResetAdminKeyFile(dbPath string) string {
|
||||||
|
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
|
||||||
|
os.Remove(p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
|
||||||
|
// persisted key file is reused; else a new 32-char hex key is generated and
|
||||||
|
// persisted with 0600 perms.
|
||||||
|
// ResolveAdminKey returns the admin key: env PALETTE_ADMIN_KEY wins; else the
|
||||||
|
// persisted key file is reused; else a new 32-char hex key is generated and
|
||||||
|
// persisted with 0600 perms.
|
||||||
|
func ResolveAdminKey(dbPath string) (string, error) {
|
||||||
|
if v := os.Getenv("PALETTE_ADMIN_KEY"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
p := filepath.Join(filepath.Dir(dbPath), "admin-key")
|
||||||
|
if b, err := os.ReadFile(p); err == nil && len(strings.TrimSpace(string(b))) >= 16 {
|
||||||
|
return strings.TrimSpace(string(b)), nil
|
||||||
|
}
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := hex.EncodeToString(b)
|
||||||
|
if err := os.WriteFile(p, []byte(key+"\n"), 0600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
log.Printf("generated admin key, persisted to %s", p)
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetAdminKey implements the --reset-admin-key flag: delete the key
|
||||||
|
// file, generate a fresh key, print it.
|
||||||
|
// HandleResetAdminKey implements the --reset-admin-key flag: delete the key
|
||||||
|
// file, generate a fresh key, print it.
|
||||||
|
func HandleResetAdminKey(dbPath string) {
|
||||||
|
p := ResetAdminKeyFile(dbPath)
|
||||||
|
key, err := ResolveAdminKey(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("reset admin key: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("admin key reset; new key written to %s:\n%s\n", p, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminKeyOK reports whether the request carries the correct admin key via
|
||||||
|
// the X-Admin-Key header only. The ?key= query fallback was removed (#137):
|
||||||
|
// query strings land in access logs, browser history, and Referer headers,
|
||||||
|
// so accepting the key there leaked the admin secret. Constant-time compare;
|
||||||
|
// failures and successes are both logged (#40).
|
||||||
|
func (a *apiServer) adminKeyOK(r *http.Request, key string) bool {
|
||||||
|
given := r.Header.Get("X-Admin-Key")
|
||||||
|
return subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) adminAuth(next http.HandlerFunc, key string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !rateLimitAdmin(r) {
|
||||||
|
log.Printf("admin auth RATE LIMITED: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||||
|
writeRateLimited(w, 60)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.adminKeyOK(r, key) {
|
||||||
|
log.Printf("admin auth FAILURE: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||||
|
writeErr(w, 401, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("admin auth OK: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, 200, a.settings.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleAdminPostSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var s Settings
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
|
||||||
|
writeErr(w, 400, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.settings.set(s); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, a.settings.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current settings (exported for cmd wiring).
|
||||||
|
func (ss *settingsStore) Get() Settings { return ss.get() }
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestSettingsStore builds an in-memory settings store with a temp file.
|
||||||
|
func NewTestSettingsStore(t *testing.T, cfg Config) *settingsStore {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
ss := LoadSettingsStore(filepath.Join(dir, "palette.db"), cfg)
|
||||||
|
// point persistence at a temp path (dir(dbPath) == dir)
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminAuth(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/admin/api/settings", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("no key: expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/admin/api/settings", nil)
|
||||||
|
req.Header.Set("X-Admin-Key", "wrong-key")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("wrong key: expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #137: the ?key= query fallback was removed; keys must go via header.
|
||||||
|
req = httptest.NewRequest("GET", "/admin/api/settings?key=test-admin-key", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("query key: expected 401 after #137 removal, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/admin/api/settings", nil)
|
||||||
|
req.Header.Set("X-Admin-Key", "test-admin-key")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("header key: expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML page itself is open (key entered via form)
|
||||||
|
req = httptest.NewRequest("GET", "/admin", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("admin page: expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminEnvKeyPrecedence(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "palette.db")
|
||||||
|
t.Setenv("PALETTE_ADMIN_KEY", "envkey1234567890abcdef")
|
||||||
|
key, err := ResolveAdminKey(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if key != "envkey1234567890abcdef" {
|
||||||
|
t.Fatalf("env key not used: %q", key)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "admin-key")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("env key should not create a key file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// unset env: file takes over
|
||||||
|
os.Unsetenv("PALETTE_ADMIN_KEY")
|
||||||
|
key2, err := ResolveAdminKey(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(key2) != 32 {
|
||||||
|
t.Fatalf("generated key should be 32 hex chars, got %d", len(key2))
|
||||||
|
}
|
||||||
|
if fi, err := os.Stat(filepath.Join(dir, "admin-key")); err != nil || fi.Mode().Perm() != 0600 {
|
||||||
|
t.Fatalf("admin-key file perms: %v err %v", fi, err)
|
||||||
|
}
|
||||||
|
// reuse on subsequent boots
|
||||||
|
key3, _ := ResolveAdminKey(dbPath)
|
||||||
|
if key3 != key2 {
|
||||||
|
t.Fatal("persisted key not reused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsRoundTrip(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
post := func(body string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(body))
|
||||||
|
req.Header.Set("X-Admin-Key", "test-admin-key")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := post(`{"rate_limit_burst": 9, "rate_limit_per_minute": 120, "max_content_bytes": 1024, "default_expiry": "1h", "custom_slug_reservation_days": 10, "burn_viewer_window_minutes": 7}`)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("post settings: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
got := s.settings.get()
|
||||||
|
if got.RateLimitBurst != 9 || got.RateLimitPerMinute != 120 || got.MaxContentBytes != 1024 ||
|
||||||
|
got.DefaultExpiry != "1h" || got.CustomSlugReservationDays != 10 || got.BurnViewerWindowMinutes != 7 {
|
||||||
|
t.Fatalf("settings not applied: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persisted to disk
|
||||||
|
b, err := os.ReadFile(s.settings.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var persisted Settings
|
||||||
|
if err := json.Unmarshal(b, &persisted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if persisted.BurnViewerWindowMinutes != 7 {
|
||||||
|
t.Fatalf("persisted settings wrong: %+v", persisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid rejected
|
||||||
|
if rec := post(`{"rate_limit_burst": -1}`); rec.Code != 400 {
|
||||||
|
t.Fatalf("invalid settings: expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := post(`{"rate_limit_burst": 5, "rate_limit_per_minute": 60, "max_content_bytes": 1024, "default_expiry": "bogus", "custom_slug_reservation_days": 10, "burn_viewer_window_minutes": 5}`); rec.Code != 400 {
|
||||||
|
t.Fatalf("bad expiry: expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings actually consumed: default expiry applied on create
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x"}`))
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ExpiresAt *int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
if created.ExpiresAt == nil {
|
||||||
|
t.Fatal("default expiry not applied to new paste")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminResetKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "palette.db")
|
||||||
|
os.Unsetenv("PALETTE_ADMIN_KEY")
|
||||||
|
key1, _ := ResolveAdminKey(dbPath)
|
||||||
|
// direct invocation of the reset behavior
|
||||||
|
ResetAdminKeyFile(dbPath)
|
||||||
|
key2, _ := ResolveAdminKey(dbPath)
|
||||||
|
if key1 == key2 {
|
||||||
|
t.Fatal("reset did not regenerate key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// #66: admin key attempts must be rate limited per IP (5/min), constant-time
|
||||||
|
// compared, and failures logged. Hammering bad keys must yield 429s.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdminKeyRateLimited: burst of 5 bad-key attempts allowed (401), the 6th
|
||||||
|
// gets 429, and even the correct key is blocked from that IP until refill.
|
||||||
|
func TestAdminKeyRateLimited(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
reqIP := "10.7.7.1:1234"
|
||||||
|
|
||||||
|
var got429, retryAfter bool
|
||||||
|
var lastCode int
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
req.Header.Set("X-Admin-Key", "wrong-key")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
lastCode = rec.Code
|
||||||
|
if rec.Code == 429 {
|
||||||
|
got429 = true
|
||||||
|
retryAfter = rec.Header().Get("Retry-After") != ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !got429 {
|
||||||
|
t.Fatalf("expected 429 after hammering bad keys, last status %d", lastCode)
|
||||||
|
}
|
||||||
|
if !retryAfter {
|
||||||
|
t.Error("429 missing Retry-After header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct key from the same IP is also locked out.
|
||||||
|
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
req.Header.Set("X-Admin-Key", srv.adminKey)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 429 {
|
||||||
|
t.Errorf("correct key after lockout: got %d, want 429", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different IP is unaffected.
|
||||||
|
req2 := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(`{"rate_limit_burst":5,"rate_limit_per_minute":60,"max_content_bytes":1048576,"custom_slug_reservation_days":30,"burn_viewer_window_minutes":15}`))
|
||||||
|
req2.RemoteAddr = "203.0.113.9:1234"
|
||||||
|
req2.Header.Set("X-Admin-Key", srv.adminKey)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != 200 {
|
||||||
|
t.Errorf("correct key from another IP: got %d, want 200", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminKeyConstantTimeCompare: sanity check that the comparison is
|
||||||
|
// constant-time (uses subtle.ConstantTimeCompare, not ==).
|
||||||
|
func TestAdminKeyConstantTimeCompare(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Admin-Key", "test-admin-key")
|
||||||
|
if !srv.adminKeyOK(r, srv.adminKey) {
|
||||||
|
t.Fatal("correct key rejected")
|
||||||
|
}
|
||||||
|
r.Header.Set("X-Admin-Key", "wrong")
|
||||||
|
if srv.adminKeyOK(r, srv.adminKey) {
|
||||||
|
t.Fatal("wrong key accepted")
|
||||||
|
}
|
||||||
|
// differ in length: must not panic/mismatch unexpectedly
|
||||||
|
r.Header.Set("X-Admin-Key", "test-admin-key-longer")
|
||||||
|
if srv.adminKeyOK(r, srv.adminKey) {
|
||||||
|
t.Fatal("longer wrong key accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #38: file attachments, iteration 1: one file per paste. A paste either has
|
||||||
|
// text content OR one attached file. Multipart create + /f/ serving route.
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxAttachmentBytes = 25 << 20 // 25 MB per file
|
||||||
|
maxFileBytesHard = MaxAttachmentBytes + 1<<20 // sniff headroom; over this reject before reading it all
|
||||||
|
)
|
||||||
|
|
||||||
|
// sniffMime runs http.DetectContentType on the first 512 bytes (and any
|
||||||
|
// remainder of the head) of r, returning the sniffed mime and a reader that
|
||||||
|
// replays the full stream. Mime is NEVER taken from the client.
|
||||||
|
func sniffMime(r io.Reader) (string, io.Reader, error) {
|
||||||
|
head := make([]byte, 512)
|
||||||
|
n, err := io.ReadFull(r, head)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
head = head[:n]
|
||||||
|
mime := http.DetectContentType(head)
|
||||||
|
return mime, io.MultiReader(bytes.NewReader(head), r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeMimeForServing maps the stored (sniffed) mime to the Content-Type
|
||||||
|
// used on /f/. Active-content types (html, svg, xml...) are forced to
|
||||||
|
// text/plain — same rule as the /raw #34 fix — so a malicious upload can
|
||||||
|
// never execute on this origin.
|
||||||
|
func serveContentType(mime string) string {
|
||||||
|
base := mime
|
||||||
|
if i := strings.IndexByte(mime, ';'); i >= 0 {
|
||||||
|
base = strings.TrimSpace(mime[:i])
|
||||||
|
}
|
||||||
|
base = strings.ToLower(base)
|
||||||
|
switch base {
|
||||||
|
case "text/html", "image/svg+xml", "application/xhtml+xml", "text/xml",
|
||||||
|
"application/xml", "application/xhtml", "image/xml+svg":
|
||||||
|
return "text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
return mime
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineable reports whether the sniffed mime is safe to render inline
|
||||||
|
// (Content-Disposition: inline); everything else downloads as an attachment.
|
||||||
|
func inlineable(mime string) bool {
|
||||||
|
base := mime
|
||||||
|
if i := strings.IndexByte(mime, ';'); i >= 0 {
|
||||||
|
base = strings.TrimSpace(mime[:i])
|
||||||
|
}
|
||||||
|
base = strings.ToLower(base)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(base, "image/"), base == "application/pdf":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// mime/multipart parts are fully read during parsing (the multipart reader
|
||||||
|
// closes each part when advancing) and the mime is sniffed from bytes.
|
||||||
|
|
||||||
|
// limitAttachment rejects reads past the 25 MB per-file cap server-side.
|
||||||
|
type limitReader struct {
|
||||||
|
r io.Reader
|
||||||
|
n int64
|
||||||
|
max int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitReader) Read(p []byte) (int, error) {
|
||||||
|
if l.n > l.max {
|
||||||
|
return 0, store.ErrFileTooLarge
|
||||||
|
}
|
||||||
|
n, err := l.r.Read(p)
|
||||||
|
l.n += int64(n)
|
||||||
|
if l.n > l.max && err == nil {
|
||||||
|
err = store.ErrFileTooLarge
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// isImageMime reports whether the sniffed mime is a raster image the viewer
|
||||||
|
// can render inline (#221). SVG is excluded: it is forced to text/plain on
|
||||||
|
// serving by the active-content rule and must never render as an image.
|
||||||
|
func isImageMime(mime string) bool {
|
||||||
|
switch mime {
|
||||||
|
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreatePasteMultipart implements POST /api/pastes with
|
||||||
|
// multipart/form-data (#38). Fields mirror the JSON create path; a 'file'
|
||||||
|
// part makes the paste a file paste (1 file = 1 paste: if text content is
|
||||||
|
// also present, the file wins and the text is ignored — simplest correct
|
||||||
|
// behavior, documented in the PR).
|
||||||
|
func (a *apiServer) handleCreatePasteMultipart(w http.ResponseWriter, r *http.Request, s Settings) {
|
||||||
|
blobs := a.store.Blobs()
|
||||||
|
if blobs == nil {
|
||||||
|
writeErr(w, 500, "blob storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// guard the raw body: 25 MB file + multipart overhead headroom
|
||||||
|
mr, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, "invalid multipart body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
p store.Paste
|
||||||
|
fileSeen bool
|
||||||
|
att store.Attachment
|
||||||
|
fileBody io.Reader
|
||||||
|
)
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if isBodyTooLarge(err) {
|
||||||
|
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, 400, "invalid multipart body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := part.FormName()
|
||||||
|
if name == "file" {
|
||||||
|
if fileSeen {
|
||||||
|
writeErrCode(w, 400, "one_file_only", "Choose either text or a file for now. Only one file per paste.")
|
||||||
|
part.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The part must be fully read during parsing: the multipart
|
||||||
|
// reader closes it as soon as the next part is fetched. Read it
|
||||||
|
// here into memory (bounded by the 25 MB cap) and sniff the mime
|
||||||
|
// from the content, never from client headers.
|
||||||
|
limited := io.LimitReader(part, MaxAttachmentBytes+1)
|
||||||
|
raw, err := io.ReadAll(limited)
|
||||||
|
part.Close()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, "invalid file part")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int64(len(raw)) > MaxAttachmentBytes {
|
||||||
|
writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large",
|
||||||
|
"File is too large. The limit is 25 MB.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
writeErrCode(w, 400, "content_empty", "The file is empty.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filename := store.SanitizeFilename(part.FileName())
|
||||||
|
mime := http.DetectContentType(raw[:min(512, len(raw))])
|
||||||
|
att = store.Attachment{PasteID: "pending", Filename: filename, Mime: mime}
|
||||||
|
fileBody = bytes.NewReader(raw)
|
||||||
|
fileSeen = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, err := io.ReadAll(io.LimitReader(part, 1<<16))
|
||||||
|
part.Close()
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, "invalid multipart field")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := string(val)
|
||||||
|
switch name {
|
||||||
|
case "content":
|
||||||
|
p.Content = v
|
||||||
|
case "title":
|
||||||
|
p.Title = &v
|
||||||
|
case "language":
|
||||||
|
p.Language = &v
|
||||||
|
case "custom_slug":
|
||||||
|
p.CustomSlug = &v
|
||||||
|
case "password":
|
||||||
|
p.Password = &v
|
||||||
|
case "expires_in":
|
||||||
|
p.ExpiresIn = &v
|
||||||
|
case "visibility":
|
||||||
|
p.Visibility = v
|
||||||
|
case "burn_after_read":
|
||||||
|
p.BurnAfterRead = v == "true" || v == "1" || v == "on"
|
||||||
|
case "burn_after_reads":
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
p.BurnAfterReads = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileSeen {
|
||||||
|
// 1 file = 1 paste: the file replaces text content.
|
||||||
|
p.Content = ""
|
||||||
|
} else if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
|
||||||
|
if status == http.StatusRequestEntityTooLarge {
|
||||||
|
writeErrCode(w, status, "content_too_large", msg)
|
||||||
|
} else {
|
||||||
|
writeErrCode(w, status, "content_empty", msg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #86 metadata bounds + default expiry: same rules as the JSON path
|
||||||
|
if p.Title != nil {
|
||||||
|
t, err := checkTitle(*p.Title)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Title = &t
|
||||||
|
}
|
||||||
|
if p.Language != nil {
|
||||||
|
l, err := checkLanguage(*p.Language)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l == "" {
|
||||||
|
p.Language = nil
|
||||||
|
} else {
|
||||||
|
p.Language = &l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.BurnAfterReads != nil {
|
||||||
|
if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
|
||||||
|
def := s.DefaultExpiry
|
||||||
|
p.ExpiresIn = &def
|
||||||
|
}
|
||||||
|
p.ViewerID = currentViewerID(r)
|
||||||
|
|
||||||
|
created, err := a.store.CreatePaste(&p)
|
||||||
|
if err != nil {
|
||||||
|
writeErrCode(w, 400, createErrCode(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143
|
||||||
|
resp := map[string]any{
|
||||||
|
"id": created.ID,
|
||||||
|
"deletion_token": created.DeletionToken,
|
||||||
|
"url": "/" + created.ID,
|
||||||
|
"raw_url": "/raw/" + created.ID,
|
||||||
|
"api_url": "/api/pastes/" + created.ID,
|
||||||
|
"expires_at": created.ExpiresAt,
|
||||||
|
"created_at": created.CreatedAt,
|
||||||
|
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileSeen {
|
||||||
|
att.PasteID = created.ID
|
||||||
|
// size pre-check happens inside the limited read; re-run with limit
|
||||||
|
// enforced so oversized uploads fail before the blob is stored.
|
||||||
|
err := a.store.CreateAttachment(&att, fileBody, blobs)
|
||||||
|
if err == store.ErrFileTooLarge {
|
||||||
|
a.store.SoftDelete(created.ID)
|
||||||
|
writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large",
|
||||||
|
"File is too large. The limit is 25 MB.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.store.SoftDelete(created.ID)
|
||||||
|
writeErr(w, 500, "could not store file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp["attachment"] = map[string]any{
|
||||||
|
"id": att.ID,
|
||||||
|
"filename": att.Filename,
|
||||||
|
"mime": att.Mime,
|
||||||
|
"size": att.Size,
|
||||||
|
"sha256": att.SHA256,
|
||||||
|
"url": "/f/" + att.ID + "/" + att.Filename,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 201, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServeAttachment serves GET /f/{attachment-id}/{filename} with the
|
||||||
|
// stored (server-sniffed) mime, nosniff, and a safe Content-Disposition.
|
||||||
|
// The filename path segment is decorative; lookups key on the attachment id.
|
||||||
|
func (a *apiServer) handleServeAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "aid")
|
||||||
|
att, err := a.store.GetAttachment(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if att == nil {
|
||||||
|
writeErr(w, 404, "attachment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// attachment inherits the paste's lifecycle: gone if the paste is gone
|
||||||
|
row, err := a.store.GetPaste(att.PasteID)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
writeErr(w, 404, "attachment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
|
writeErr(w, 404, "attachment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Burned() {
|
||||||
|
writeErr(w, 404, "attachment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
blobs := a.store.Blobs()
|
||||||
|
if blobs == nil {
|
||||||
|
writeErr(w, 500, "blob storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blob, err := blobs.Get(att.PasteID + "/" + att.SHA256)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 404, "attachment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer blob.Close()
|
||||||
|
|
||||||
|
ct := serveContentType(att.Mime)
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
disposition := "attachment"
|
||||||
|
if inlineable(att.Mime) {
|
||||||
|
disposition = "inline"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition",
|
||||||
|
fmt.Sprintf(`%s; filename="%s"`, disposition, asciiFilename(att.Filename)))
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
|
||||||
|
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// asciiFilename quotes a filename for the Content-Disposition header,
|
||||||
|
// escaping quotes and backslashes and dropping non-ASCII bytes.
|
||||||
|
func asciiFilename(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
if r < 128 && r != '"' && r != '\\' && r > 31 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// multipartCreate posts a multipart create to the routes handler; extra
|
||||||
|
// fields are appended as text parts. Returns recorder and parsed response.
|
||||||
|
func multipartCreate(t *testing.T, h http.Handler, filename string, content []byte, fields map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
if filename != "" {
|
||||||
|
fw, _ := mw.CreateFormFile("file", filename)
|
||||||
|
fw.Write(content)
|
||||||
|
}
|
||||||
|
for k, v := range fields {
|
||||||
|
mw.WriteField(k, v)
|
||||||
|
}
|
||||||
|
mw.Close()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var resp map[string]any
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
return rec, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartAttachmentCreateAndServe(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0, 1, 2, 3}, 32)...)
|
||||||
|
rec, resp := multipartCreate(t, h, "shot.png", png, map[string]string{"title": "with file"})
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
att, _ := resp["attachment"].(map[string]any)
|
||||||
|
if att == nil {
|
||||||
|
t.Fatalf("no attachment in response: %v", resp)
|
||||||
|
}
|
||||||
|
id, _ := att["id"].(string)
|
||||||
|
url, _ := att["url"].(string)
|
||||||
|
if url != "/f/"+id+"/shot.png" {
|
||||||
|
t.Fatalf("attachment url = %q", url)
|
||||||
|
}
|
||||||
|
if att["mime"] != "image/png" {
|
||||||
|
t.Fatalf("sniffed mime = %v want image/png", att["mime"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve: image mime -> inline, nosniff, stored bytes
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
if rec2.Code != 200 {
|
||||||
|
t.Fatalf("serve: %d %s", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec2.Header().Get("Content-Type"); got != "image/png" {
|
||||||
|
t.Fatalf("Content-Type = %q", got)
|
||||||
|
}
|
||||||
|
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||||
|
t.Fatalf("nosniff = %q", got)
|
||||||
|
}
|
||||||
|
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
|
||||||
|
t.Fatalf("Content-Disposition = %q", got)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec2.Body.Bytes(), png) {
|
||||||
|
t.Fatal("served bytes differ from upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartFileReplacesText(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
rec, resp := multipartCreate(t, h, "notes.txt", []byte("file body"), map[string]string{"content": "some text"})
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
id, _ := resp["id"].(string)
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
var got map[string]any
|
||||||
|
json.Unmarshal(rec2.Body.Bytes(), &got)
|
||||||
|
if got["content"] != "" {
|
||||||
|
t.Fatalf("content should be empty when file provided, got %v", got["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartSecondFileRejected(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
for _, name := range []string{"a.txt", "b.txt"} {
|
||||||
|
fw, _ := mw.CreateFormFile("file", name)
|
||||||
|
fw.Write([]byte("x"))
|
||||||
|
}
|
||||||
|
mw.Close()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Fatalf("two files: got %d want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "one_file_only") {
|
||||||
|
t.Fatalf("error code missing: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartHtmlUploadServesAsPlainText(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
html := []byte("<html><script>alert(1)</script></html>")
|
||||||
|
rec, resp := multipartCreate(t, h, "page.html", html, nil)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
||||||
|
t.Fatalf("html served as %q, want text/plain", got)
|
||||||
|
}
|
||||||
|
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
|
||||||
|
t.Fatalf("html Content-Disposition = %q, want attachment", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartSvgUploadServesAsPlainText(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
||||||
|
rec, resp := multipartCreate(t, h, "evil.svg", svg, nil)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
ct := rec2.Header().Get("Content-Type")
|
||||||
|
if strings.Contains(ct, "svg") || strings.Contains(ct, "html") {
|
||||||
|
t.Fatalf("svg served as %q", ct)
|
||||||
|
}
|
||||||
|
if ct != "text/plain; charset=utf-8" {
|
||||||
|
t.Fatalf("svg Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartClientMimeIgnored(t *testing.T) {
|
||||||
|
// client claims image/png; server must sniff the real type (text)
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
fw, _ := mw.CreateFormFile("file", "fake.png")
|
||||||
|
fw.Write([]byte("just plain text, definitely not a png"))
|
||||||
|
// note: CreateFormFile sets Content-Type: application/octet-stream; the
|
||||||
|
// sniffed type for text content is text/plain either way.
|
||||||
|
mw.Close()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
att := resp["attachment"].(map[string]any)
|
||||||
|
if att["mime"] != "text/plain; charset=utf-8" && att["mime"] != "text/plain" {
|
||||||
|
t.Fatalf("mime = %v, want sniffed text/plain", att["mime"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartOversizeRejected(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
big := bytes.Repeat([]byte("A"), MaxAttachmentBytes+1024)
|
||||||
|
rec, _ := multipartCreate(t, h, "big.bin", big, nil)
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("oversize: got %d want 413", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartExactlyAtLimitAccepted(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
exact := bytes.Repeat([]byte("A"), MaxAttachmentBytes)
|
||||||
|
rec, resp := multipartCreate(t, h, "exact.bin", exact, nil)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("at-limit: got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
att := resp["attachment"].(map[string]any)
|
||||||
|
if att["size"].(float64) != float64(MaxAttachmentBytes) {
|
||||||
|
t.Fatalf("size = %v", att["size"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartEmptyFileRejected(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
rec, _ := multipartCreate(t, h, "empty.txt", nil, nil)
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Fatalf("empty file: got %d want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeAttachment404Missing(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
req := httptest.NewRequest("GET", "/f/zzzzzzzz/nonexistent.txt", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("missing attachment: got %d want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeAttachmentUnknownPaste404(t *testing.T) {
|
||||||
|
// attachment row referencing a paste that doesn't exist must 404, not leak
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
s.store.CreatePaste(&store.Paste{Content: "x"})
|
||||||
|
att := store.Attachment{PasteID: "ghost00", Filename: "f.txt", Mime: "text/plain"}
|
||||||
|
blobs := s.store.Blobs()
|
||||||
|
if err := s.store.CreateAttachment(&att, strings.NewReader("hello"), blobs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("GET", "/f/"+att.ID+"/f.txt", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("orphan attachment: got %d want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeAttachmentPdfInline(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
pdf := []byte("%PDF-1.4\n%fake pdf body\n")
|
||||||
|
rec, resp := multipartCreate(t, h, "doc.pdf", pdf, nil)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
if got := rec2.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/pdf") {
|
||||||
|
t.Fatalf("pdf Content-Type = %q", got)
|
||||||
|
}
|
||||||
|
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
|
||||||
|
t.Fatalf("pdf Content-Disposition = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeAttachmentBurnedPaste404(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
rec, resp := multipartCreate(t, h, "burn.txt", []byte("burn me"), nil)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d", rec.Code)
|
||||||
|
}
|
||||||
|
att := resp["attachment"].(map[string]any)
|
||||||
|
url, _ := att["url"].(string)
|
||||||
|
pid, _ := resp["id"].(string)
|
||||||
|
// burn the paste via API read (burn_after_read default off here, so force)
|
||||||
|
s.store.SoftDelete(pid)
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
if rec2.Code != 404 {
|
||||||
|
t.Fatalf("deleted paste attachment: got %d want 404", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartPasswordFieldAccepted(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
rec, resp := multipartCreate(t, h, "secret.txt", []byte("top secret"),
|
||||||
|
map[string]string{"password": "hunter2", "expires_in": "1h"})
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if resp["attachment"] == nil {
|
||||||
|
t.Fatal("attachment missing")
|
||||||
|
}
|
||||||
|
id, _ := resp["id"].(string)
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req)
|
||||||
|
if rec2.Code != 401 {
|
||||||
|
t.Fatalf("paste should require password, got %d", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// burnViewerWindow returns the admin-tunable per-viewer dedupe window
|
||||||
|
// (#40), falling back to the 15-minute default from #49.
|
||||||
|
func (a *apiServer) burnViewerWindow() int {
|
||||||
|
if a.settings != nil {
|
||||||
|
if m := a.settings.get().BurnViewerWindowMinutes; m > 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
|
||||||
|
// DELETE /api/pastes/{id}/redeem with the token in the Authorization header
|
||||||
|
// (#143: the ?token= query path was removed so the secret stays out of
|
||||||
|
// access logs and browser history).
|
||||||
|
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
token := deletionAuthorization(r)
|
||||||
|
if token == "" {
|
||||||
|
writeErr(w, 400, "token required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := a.store.GetPaste(id)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.DeletionToken.String == "" || !store.DeletionTokenEqual(row.DeletionToken.String, token) {
|
||||||
|
writeErr(w, 403, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// hard delete: pastes table row goes away entirely
|
||||||
|
a.store.HardDelete(row.ID)
|
||||||
|
writeJSON(w, 200, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -53,8 +53,9 @@ func TestDeletionTokenRedeem(t *testing.T) {
|
|||||||
t.Fatal("no deletion token in create response")
|
t.Fatal("no deletion token in create response")
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrong token
|
// wrong token (#143: token goes in the Authorization header, not the URL)
|
||||||
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token=wrong", nil)
|
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != 403 {
|
if rec.Code != 403 {
|
||||||
@@ -62,7 +63,8 @@ func TestDeletionTokenRedeem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// right token: hard delete
|
// right token: hard delete
|
||||||
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token="+created.DeletionToken, nil)
|
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+created.DeletionToken)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != 200 {
|
if rec.Code != 200 {
|
||||||
@@ -71,7 +73,7 @@ func TestDeletionTokenRedeem(t *testing.T) {
|
|||||||
|
|
||||||
// gone for good: even soft-deleted lookup returns nothing, and row count is 0
|
// gone for good: even soft-deleted lookup returns nothing, and row count is 0
|
||||||
var n int
|
var n int
|
||||||
s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&n)
|
n = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
|
||||||
if n != 0 {
|
if n != 0 {
|
||||||
t.Fatal("row still exists after redeem")
|
t.Fatal("row still exists after redeem")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBurnAfterReadConcurrentRace is the #58 regression test: N concurrent
|
||||||
|
// readers of a burn-after-read paste must receive exactly one success with
|
||||||
|
// content; every other reader must get 404 and never any content.
|
||||||
|
func TestBurnAfterReadConcurrentRace(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createBurnReads(t, h, 1)
|
||||||
|
|
||||||
|
const readers = 24
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
wins, losses := 0, 0
|
||||||
|
for i := 0; i < readers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if rec.Code == 200 {
|
||||||
|
wins++
|
||||||
|
var got struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Content != "limited" {
|
||||||
|
t.Errorf("winning read returned wrong content: %v %q", err, got.Content)
|
||||||
|
}
|
||||||
|
} else if rec.Code == 404 {
|
||||||
|
losses++
|
||||||
|
if strings.Contains(rec.Body.String(), "limited") {
|
||||||
|
t.Errorf("losing read leaked content: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if wins != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 winning read of burn paste, got %d (losses=%d)", wins, losses)
|
||||||
|
}
|
||||||
|
if losses != readers-1 {
|
||||||
|
t.Fatalf("expected %d losing reads, got %d", readers-1, losses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBurnAfterNReadsConcurrentBudget hammers a burn-after-N paste with many
|
||||||
|
// more concurrent distinct readers than the budget: total admissions must
|
||||||
|
// equal exactly N, and no losing read may see content.
|
||||||
|
func TestBurnAfterNReadsConcurrentBudget(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
const budget = 3
|
||||||
|
id := createBurnReads(t, h, budget)
|
||||||
|
|
||||||
|
const readers = 30
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
wins := 0
|
||||||
|
for i := 0; i < readers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if rec.Code == 200 {
|
||||||
|
wins++
|
||||||
|
} else if rec.Code != 404 {
|
||||||
|
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if wins != budget {
|
||||||
|
t.Fatalf("expected exactly %d admitted reads, got %d", budget, wins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the race, the paste is burned for everyone.
|
||||||
|
if rec := getWithCookie(t, h, id, "after-the-fact"); rec.Code != 404 {
|
||||||
|
t.Fatalf("paste should be burned after budget exhausted, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type anyHandler interface {
|
||||||
|
ServeHTTP(http.ResponseWriter, *http.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createBurnReads creates a burn-after-N-reads paste and returns its id.
|
||||||
|
func createBurnReads(t *testing.T, h anyHandler, reads int) string {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "limited", "burn_after_read": true, "burn_after_reads": reads})
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(string(body)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create burn_after_reads=%d: %d %s", reads, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
if created.ID == "" {
|
||||||
|
t.Fatal("no id in create response")
|
||||||
|
}
|
||||||
|
return created.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
if viewer != "" {
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: viewer})
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// #82: burn_after_reads > 0 alone must enable burn-after-read
|
||||||
|
// even without burn_after_read: true.
|
||||||
|
func TestBurnReadsImpliedByBurnAfterReads(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"implied","burn_after_reads":2}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create burn_after_reads-only: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 1: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 2: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := getWithCookie(t, h, created.ID, "ccc"); rec.Code != 404 {
|
||||||
|
t.Fatalf("read 3 expected 404 (burned), got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBurnAfterNReadsDistinctViewers(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createBurnReads(t, h, 2)
|
||||||
|
|
||||||
|
// viewer A: ok (read 1)
|
||||||
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 1 (viewer A): %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// viewer B: ok (read 2)
|
||||||
|
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 2 (viewer B): %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// viewer C: burned -> 404
|
||||||
|
if rec := getWithCookie(t, h, id, "ccc"); rec.Code != 404 {
|
||||||
|
t.Fatalf("read 3 expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBurnReadsSameViewerWithinWindowNoDecrement(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createBurnReads(t, h, 2)
|
||||||
|
|
||||||
|
// same viewer reads twice within the window: second is deduped
|
||||||
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 1: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("deduped re-read expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// another viewer still gets read 2 (budget not consumed by re-reads)
|
||||||
|
if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 2: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBurnReadsWindowExpiryRecounts(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createBurnReads(t, h, 2)
|
||||||
|
|
||||||
|
base := time.Now()
|
||||||
|
store.TimeNow = func() time.Time { return base }
|
||||||
|
t.Cleanup(func() { store.TimeNow = time.Now })
|
||||||
|
|
||||||
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 1: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// 10 minutes later: still within window, deduped
|
||||||
|
store.TimeNow = func() time.Time { return base.Add(10 * time.Minute) }
|
||||||
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("re-read within window: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// 20 minutes after first read: window expired, counts as read 2
|
||||||
|
store.TimeNow = func() time.Time { return base.Add(20 * time.Minute) }
|
||||||
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("re-read after window expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// budget exhausted -> 404 even for the same viewer
|
||||||
|
if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 404 {
|
||||||
|
t.Fatalf("after budget expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBurnReadsDefaultOne(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
// burn_after_read without burn_after_reads defaults to 1 read
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"one","burn_after_read":true}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 {
|
||||||
|
t.Fatalf("read 1: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 404 {
|
||||||
|
t.Fatalf("read 2 expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBurnReadsPageViewCounts(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createBurnReads(t, h, 2)
|
||||||
|
|
||||||
|
// HTML page view counts as a read too (documented decision)
|
||||||
|
req := httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("page view 1: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// re-view within window: deduped
|
||||||
|
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("page re-view: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// distinct viewer: read 2, page renders with reads remaining
|
||||||
|
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "bbb"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("page view 2: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Reads left") {
|
||||||
|
t.Fatal("stats pill missing 'Reads left'")
|
||||||
|
}
|
||||||
|
// third viewer: burned
|
||||||
|
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "ccc"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("page view 3 expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"palette/internal/store"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"palette/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateCan makes a can with N items (multipart form).
|
// CreateCan makes a can with N items (multipart form).
|
||||||
@@ -26,18 +28,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
visibility = "public"
|
visibility = "public"
|
||||||
}
|
}
|
||||||
if visibility != "public" && visibility != "unlisted" {
|
if visibility != "public" && visibility != "unlisted" {
|
||||||
writeErr(w, 400, "visibility must be public or unlisted")
|
writeErrCode(w, 400, "invalid_visibility", "visibility must be public or unlisted")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expiresIn := r.FormValue("expires_in")
|
expiresIn := r.FormValue("expires_in")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
customSlug := r.FormValue("custom_slug")
|
||||||
|
|
||||||
var expiresAt *int64
|
var expiresAt *int64
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
if expiresIn != "" {
|
if expiresIn != "" {
|
||||||
d, err := time.ParseDuration(expiresIn)
|
d, err := time.ParseDuration(expiresIn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 400, "invalid expires_in")
|
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #60/#48: clamp at the API boundary like the pastes API does -
|
||||||
|
// reject zero/negative and durations past the 1-year UI cap.
|
||||||
|
if !store.ValidExpiry(d) {
|
||||||
|
writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t := now + int64(d.Seconds())
|
t := now + int64(d.Seconds())
|
||||||
@@ -45,7 +54,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var pwHash *string
|
var pwHash *string
|
||||||
if password != "" {
|
if password != "" {
|
||||||
h, err := hashPassword(password)
|
h, err := store.Argon2IDHash(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, "hash error")
|
writeErr(w, 500, "hash error")
|
||||||
return
|
return
|
||||||
@@ -53,13 +62,26 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
pwHash = &h
|
pwHash = &h
|
||||||
}
|
}
|
||||||
|
|
||||||
canID := genSlug(8)
|
canID := store.GenSlug(8)
|
||||||
_, err := a.store.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
|
var slugPtr *string
|
||||||
VALUES (?,?,?,?,?,?,?)`, canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
|
if customSlug != "" {
|
||||||
|
slugPtr = &customSlug
|
||||||
|
}
|
||||||
|
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, "db error")
|
switch err {
|
||||||
|
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
|
||||||
|
writeErrCode(w, 409, createErrCode(err), err.Error())
|
||||||
|
default:
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if slugPtr != nil {
|
||||||
|
canID = customSlug // #4: custom slug becomes the can id
|
||||||
|
}
|
||||||
|
// #4: remember the creating browser so /mine and viewer-scoped delete work
|
||||||
|
a.store.Exec(`UPDATE paste_cans SET viewer_id=? WHERE id=?`, currentViewerID(r), canID)
|
||||||
|
|
||||||
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
|
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
|
||||||
itemCount := 0
|
itemCount := 0
|
||||||
@@ -76,7 +98,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
lang := it["language"]
|
lang := it["language"]
|
||||||
if err := a.store.insertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
|
if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
|
||||||
writeErr(w, 500, "db error")
|
writeErr(w, 500, "db error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -104,7 +126,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
contentStr := string(content)
|
contentStr := string(content)
|
||||||
if err := a.store.insertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
|
if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
|
||||||
writeErr(w, 500, "db error")
|
writeErr(w, 500, "db error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -114,8 +136,8 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if itemCount == 0 {
|
if itemCount == 0 {
|
||||||
a.store.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
|
a.store.DeleteCan(canID)
|
||||||
writeErr(w, 400, "can needs at least one item (files or json_items)")
|
writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +146,29 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteCan soft-deletes a can (parity with paste deletion, #63):
|
||||||
|
// requires the vwr viewer cookie matching the can's viewer (cans carry no
|
||||||
|
// deletion token since they are built in the browser).
|
||||||
|
func (a *apiServer) handleDeleteCan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
can, err := a.store.GetCan(id)
|
||||||
|
if err != nil || can == nil {
|
||||||
|
writeErr(w, 404, "can not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vid := currentViewerID(r)
|
||||||
|
if !(vid != "" && viewerSentCookie(r) && can.ViewerID.Valid &&
|
||||||
|
can.ViewerID.String != "" && can.ViewerID.String == vid) {
|
||||||
|
writeErr(w, 403, "deletion not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.store.SoftDeleteCan(can.ID); err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
func detectContentType(name string, content []byte) string {
|
func detectContentType(name string, content []byte) string {
|
||||||
lower := strings.ToLower(name)
|
lower := strings.ToLower(name)
|
||||||
switch {
|
switch {
|
||||||
@@ -144,46 +189,6 @@ func detectContentType(name string, content []byte) string {
|
|||||||
return "text/plain"
|
return "text/plain"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) insertCanItem(canID, title, content, contentType string, language, expiresAt *string, binary *string, now int64) error {
|
|
||||||
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
|
|
||||||
_, err := s.db.Exec(`INSERT INTO pastes
|
|
||||||
(id, content, content_type, language, title, visibility, can_id, created_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?)`,
|
|
||||||
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
|
|
||||||
_ = expiresAt
|
|
||||||
_ = binary
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetCan(id string) (*CanRow, error) {
|
|
||||||
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at
|
|
||||||
FROM paste_cans WHERE id = ? AND deleted_at IS NULL`, id)
|
|
||||||
var c CanRow
|
|
||||||
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &c, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
|
|
||||||
rows, err := s.db.Query(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count
|
|
||||||
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []PasteRow
|
|
||||||
for rows.Next() {
|
|
||||||
var r PasteRow
|
|
||||||
if err := rows.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
can, err := a.store.GetCan(id)
|
can, err := a.store.GetCan(id)
|
||||||
@@ -201,10 +206,7 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if can.PasswordHash.Valid {
|
if can.PasswordHash.Valid {
|
||||||
pw := r.Header.Get("X-Paste-Password")
|
pw := r.Header.Get("X-Paste-Password")
|
||||||
if pw == "" {
|
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
|
||||||
pw = r.URL.Query().Get("password")
|
|
||||||
}
|
|
||||||
if pw == "" || !checkPassword(can.PasswordHash.String, pw) {
|
|
||||||
writeErr(w, 401, "password required")
|
writeErr(w, 401, "password required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -224,12 +226,12 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
|||||||
metas := make([]itemMeta, 0, len(items))
|
metas := make([]itemMeta, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
metas = append(metas, itemMeta{
|
metas = append(metas, itemMeta{
|
||||||
ID: it.ID, Title: nullStrPtr(it.Title), ContentType: it.ContentType,
|
ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
|
||||||
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
|
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{
|
writeJSON(w, 200, map[string]any{
|
||||||
"id": can.ID, "title": nullStrPtr(can.Title), "visibility": can.Visibility,
|
"id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
|
||||||
"created_at": can.CreatedAt, "items": metas,
|
"created_at": can.CreatedAt, "items": metas,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -246,18 +248,26 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 404, "not a can item")
|
writeErr(w, 404, "not a can item")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// inherit can password protection
|
// inherit can password protection: password via header/query, or the
|
||||||
|
// same pw_<can> unlock cookie the can page sets (#4 cookie parity).
|
||||||
can, _ := a.store.GetCan(row.CanID.String)
|
can, _ := a.store.GetCan(row.CanID.String)
|
||||||
if can != nil && can.PasswordHash.Valid {
|
if can != nil && can.PasswordHash.Valid {
|
||||||
pw := r.Header.Get("X-Paste-Password")
|
pw := r.Header.Get("X-Paste-Password")
|
||||||
if pw == "" {
|
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
|
||||||
pw = r.URL.Query().Get("password")
|
// fall back to the browser's unlock cookie for this can
|
||||||
}
|
c, cerr := r.Cookie("pw_" + can.ID)
|
||||||
if pw == "" || !checkPassword(can.PasswordHash.String, pw) {
|
if cerr != nil || c.Value != web.UnlockToken(can.ID) {
|
||||||
writeErr(w, 401, "password required")
|
writeErr(w, 401, "password required")
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", row.ContentType)
|
// #34: same content-type guard as /raw — never serve active content types.
|
||||||
|
ct := row.ContentType
|
||||||
|
if !safeRawContentType(ct) {
|
||||||
|
ct = "text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Write([]byte(row.Content))
|
w.Write([]byte(row.Content))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #60: the cans API must clamp expires_in at the boundary exactly like the
|
||||||
|
// pastes API — reject zero/negative durations and anything over the 1-year
|
||||||
|
// UI cap, accept the exact boundaries.
|
||||||
|
func TestCreateCanExpiryBounds(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
expiresIn string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{"-1h", 400}, // negative
|
||||||
|
{"-0s", 400}, // negative zero
|
||||||
|
{"0s", 400}, // zero
|
||||||
|
{"1ns", 400}, // positive but below the 1-minute floor
|
||||||
|
{"59s", 400}, // just under the floor
|
||||||
|
{"1m", 201}, // exactly the floor
|
||||||
|
{"90s", 201}, // just over the floor
|
||||||
|
{"8760h", 201}, // exactly 1 year
|
||||||
|
{"8785h", 400}, // 1 year + 1 day: over the cap
|
||||||
|
{"87600h", 400}, // 10 years, the originally reported case
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
globalLimiter = newLimiter() // avoid create rate limit between cases
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
"expires_in": c.expiresIn,
|
||||||
|
}, "files", "pic.txt", "file data")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != c.wantCode {
|
||||||
|
t.Errorf("expires_in %q: got %d want %d (%s)",
|
||||||
|
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #4: cans appear in /api/public as normal rows with is_can=true.
|
||||||
|
func TestCanInPublicListing(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
// a can and a regular paste
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Listed can",
|
||||||
|
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create can: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"plain"}`))
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/api/public", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("public: %d", rec.Code)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IsCan bool `json:"is_can"`
|
||||||
|
Title any `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if got.Total != 2 {
|
||||||
|
t.Fatalf("expected total 2 (can + paste), got %d", got.Total)
|
||||||
|
}
|
||||||
|
foundCan := false
|
||||||
|
for _, it := range got.Items {
|
||||||
|
if it.ID == created.ID {
|
||||||
|
if !it.IsCan {
|
||||||
|
t.Fatalf("can row missing is_can flag")
|
||||||
|
}
|
||||||
|
foundCan = true
|
||||||
|
} else if it.IsCan {
|
||||||
|
t.Fatalf("plain paste flagged as can")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundCan {
|
||||||
|
t.Fatalf("can not present in /api/public")
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: unlisted can must not be listed
|
||||||
|
body2, ct2 := multipartBody(t, map[string]string{
|
||||||
|
"title": "Hidden can",
|
||||||
|
"visibility": "unlisted",
|
||||||
|
"json_items": `[{"title":"b.txt","content":"BBB"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req = httptest.NewRequest("POST", "/api/pastes/can", body2)
|
||||||
|
req.Header.Set("Content-Type", ct2)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("unlisted can create: %d", rec.Code)
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest("GET", "/api/public", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if strings.Contains(rec.Body.String(), "Hidden can") {
|
||||||
|
t.Fatalf("unlisted can leaked into /api/public")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: custom slug support for cans + conflict with existing paste slug.
|
||||||
|
func TestCanCustomSlug(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Slugged",
|
||||||
|
"custom_slug": "my-bundle",
|
||||||
|
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create can with slug: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct{ ID, URL string }
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
if created.ID != "my-bundle" {
|
||||||
|
t.Fatalf("expected id my-bundle, got %q", created.ID)
|
||||||
|
}
|
||||||
|
// custom slug resolves on the can page route
|
||||||
|
req = httptest.NewRequest("GET", "/can/my-bundle", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Slugged") {
|
||||||
|
t.Fatalf("can page by slug: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// duplicate slug rejected 409
|
||||||
|
body, ct = multipartBody(t, map[string]string{
|
||||||
|
"title": "Again", "custom_slug": "my-bundle",
|
||||||
|
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req = httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 409 {
|
||||||
|
t.Fatalf("duplicate slug expected 409, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// reserved slug rejected
|
||||||
|
body, ct = multipartBody(t, map[string]string{
|
||||||
|
"title": "R", "custom_slug": "admin",
|
||||||
|
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req = httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 409 {
|
||||||
|
t.Fatalf("reserved slug expected 409, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: invalid expiry rejected on cans, mirroring paste behavior (#48).
|
||||||
|
func TestCanExpiryValidation(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Bad expiry", "expires_in": "30s",
|
||||||
|
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Fatalf("expected 400 for 30s expiry, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: expired cans 404 on the page and API.
|
||||||
|
func TestCanExpiryAccess(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Dying", "expires_in": "1h",
|
||||||
|
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var created struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
// backdate expiry to force the expired path
|
||||||
|
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, created.ID)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("expired can API expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("expired can page expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: password-protected can page uses the same unlock flow as pastes.
|
||||||
|
func TestCanUnlockFlow(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Locked can", "password": "pw123",
|
||||||
|
"json_items": `[{"title":"secret.txt","content":"sec content"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var created struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
// GET page without cookie -> unlock form
|
||||||
|
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "locked") {
|
||||||
|
t.Fatalf("expected unlock form, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST wrong password -> unlock form with error
|
||||||
|
fd := strings.NewReader("password=wrong")
|
||||||
|
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if !strings.Contains(rec.Body.String(), "Wrong password") {
|
||||||
|
t.Fatalf("expected wrong-password message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST correct password -> page renders, cookie set
|
||||||
|
fd = strings.NewReader("password=pw123")
|
||||||
|
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if !strings.Contains(rec.Body.String(), "sec content") {
|
||||||
|
t.Fatalf("unlocked can page missing item content")
|
||||||
|
}
|
||||||
|
var pwCookie *http.Cookie
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "pw_"+created.ID {
|
||||||
|
pwCookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pwCookie == nil {
|
||||||
|
t.Fatalf("unlock cookie not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET with cookie -> unlocked
|
||||||
|
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||||
|
req.AddCookie(pwCookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if !strings.Contains(rec.Body.String(), "sec content") {
|
||||||
|
t.Fatalf("cookie unlock failed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// a forged cookie value must not unlock
|
||||||
|
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if strings.Contains(rec.Body.String(), "sec content") {
|
||||||
|
t.Fatalf("forged cookie unlocked the can")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: can item access inherits the can unlock cookie (not just query param).
|
||||||
|
func TestCanItemCookieParity(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Cookie can", "password": "pw123",
|
||||||
|
"json_items": `[{"title":"s.txt","content":"sec"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var created struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
// unlock via page to get the cookie
|
||||||
|
fd := strings.NewReader("password=pw123")
|
||||||
|
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var pwCookie *http.Cookie
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "pw_"+created.ID {
|
||||||
|
pwCookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pwCookie == nil {
|
||||||
|
t.Fatalf("no unlock cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
// item id from API (with password query)
|
||||||
|
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "pw123")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var can struct {
|
||||||
|
Items []struct{ ID string `json:"id"` } `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &can)
|
||||||
|
itemID := can.Items[0].ID
|
||||||
|
|
||||||
|
// API item with the unlock cookie but no password -> 200
|
||||||
|
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
|
||||||
|
req.AddCookie(pwCookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("item via cookie expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// forged cookie -> 401
|
||||||
|
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("forged cookie on item expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: viewer-scoped soft delete of cans.
|
||||||
|
func TestCanDeleteParity(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Doomed", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var created struct{ ID string `json:"id"` }
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
// delete without matching viewer -> 403
|
||||||
|
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 403 {
|
||||||
|
t.Fatalf("unauthorized delete expected 403, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// creator's browser -> 200
|
||||||
|
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("creator delete expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// gone from API and page
|
||||||
|
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("deleted can expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
// gone from listings
|
||||||
|
req = httptest.NewRequest("GET", "/api/public", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if strings.Contains(rec.Body.String(), "Doomed") {
|
||||||
|
t.Fatalf("deleted can still listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: cans appear in /api/mine for the creating browser.
|
||||||
|
func TestCanInMine(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
body, ct := multipartBody(t, map[string]string{
|
||||||
|
"title": "Mine can", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||||
|
}, "", "", "")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||||
|
req.Header.Set("Content-Type", ct)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/api/mine", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var got struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IsCan bool `json:"is_can"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if len(got.Items) != 1 || !got.Items[0].IsCan {
|
||||||
|
t.Fatalf("can missing from /api/mine: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #4: sweep soft-deletes expired cans (parity with pastes).
|
||||||
|
func TestCanSweepExpired(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
s.store.CreateCan("sweepcan", "sweep", "", "public", nil, 1, nil, nil)
|
||||||
|
s.store.InsertCanItem("sweepcan", "a.txt", "AAA", "text/plain", nil, nil, nil, 1)
|
||||||
|
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, "sweepcan")
|
||||||
|
s.store.SweepExpired()
|
||||||
|
if can, _ := s.store.GetCan("sweepcan"); can != nil {
|
||||||
|
t.Fatalf("expired can survived sweep")
|
||||||
|
}
|
||||||
|
_ = store.GenSlug(4) // keep import if store pkg shrinks
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -112,7 +112,8 @@ func TestCanPasswordInheritedByItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get item id with pw
|
// get item id with pw
|
||||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"?password=pw123", nil)
|
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "pw123")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
var can struct {
|
var can struct {
|
||||||
@@ -130,7 +131,8 @@ func TestCanPasswordInheritedByItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// item with pw -> 200
|
// item with pw -> 200
|
||||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID+"?password=pw123", nil)
|
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "pw123")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != 200 {
|
if rec.Code != 200 {
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// #143: create responses must set the short-lived tok_<id> HttpOnly cookie
|
||||||
|
// that the paste view reads for the one-time created banner.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateSetsDeletionTokenCookie(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
// JSON create
|
||||||
|
body := `{"content":"hello #143 cookie"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("json create: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if strings.HasPrefix(c.Name, "tok_") && c.Value != "" {
|
||||||
|
found = true
|
||||||
|
if !c.HttpOnly {
|
||||||
|
t.Error("tok_ cookie not HttpOnly")
|
||||||
|
}
|
||||||
|
if c.MaxAge != 60 {
|
||||||
|
t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("json create did not set tok_<id> cookie (#143)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipart create
|
||||||
|
var buf strings.Builder
|
||||||
|
boundary := "----qa143"
|
||||||
|
buf.WriteString("--" + boundary + "\r\n")
|
||||||
|
buf.WriteString("Content-Disposition: form-data; name=\"content\"\r\n\r\n")
|
||||||
|
buf.WriteString("multipart #143\r\n")
|
||||||
|
buf.WriteString("--" + boundary + "--\r\n")
|
||||||
|
req2 := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(buf.String()))
|
||||||
|
req2.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != 201 {
|
||||||
|
t.Fatalf("multipart create: got %d body=%s", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
found = false
|
||||||
|
for _, c := range rec2.Result().Cookies() {
|
||||||
|
if strings.HasPrefix(c.Name, "tok_") && c.Value != "" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("multipart create did not set tok_<id> cookie (#143)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatedBannerViaCookie(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
body := `{"content":"banner flow #143"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var id, tok string
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if strings.HasPrefix(c.Name, "tok_") {
|
||||||
|
id = strings.TrimPrefix(c.Name, "tok_")
|
||||||
|
tok = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == "" || tok == "" {
|
||||||
|
t.Fatal("no tok_ cookie from create")
|
||||||
|
}
|
||||||
|
// follow the redirect the browser would make: GET /<id>?created=1 with the cookie
|
||||||
|
req2 := httptest.NewRequest("GET", "/"+id+"?created=1", nil)
|
||||||
|
req2.AddCookie(&http.Cookie{Name: "tok_" + id, Value: tok})
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != 200 {
|
||||||
|
t.Fatalf("paste view: got %d", rec2.Code)
|
||||||
|
}
|
||||||
|
// #168: the created pill no longer prints the deletion token; the token
|
||||||
|
// still arrives via the one-time cookie flow (re-set on the view response).
|
||||||
|
var got bool
|
||||||
|
for _, c := range rec2.Result().Cookies() {
|
||||||
|
if c.Name == "tok_"+id && c.Value == tok {
|
||||||
|
got = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
t.Error("created view did not re-set the deletion token cookie (#143 cookie flow broken)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -47,8 +49,9 @@ func TestCustomSlugValidation(t *testing.T) {
|
|||||||
{"bad slug", `{"content":"x","custom_slug":"has space"}`, 400},
|
{"bad slug", `{"content":"x","custom_slug":"has space"}`, 400},
|
||||||
{"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine
|
{"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for i, c := range cases {
|
||||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body))
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body))
|
||||||
|
req.RemoteAddr = "10.7.1." + string(rune('1'+i)) + ":1000" // avoid rate-limit bucket sharing
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != c.wantCode {
|
if rec.Code != c.wantCode {
|
||||||
@@ -60,7 +63,7 @@ func TestCustomSlugValidation(t *testing.T) {
|
|||||||
func TestSlugCollisionWithAutoID(t *testing.T) {
|
func TestSlugCollisionWithAutoID(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
// manually insert a paste, then try to claim its auto ID as a custom slug
|
// manually insert a paste, then try to claim its auto ID as a custom slug
|
||||||
p, err := s.store.CreatePaste(&Paste{Content: "auto"})
|
p, err := s.store.CreatePaste(&store.Paste{Content: "auto"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Regression tests for #63 and #143: DELETE /api/pastes/{id} must require the
|
||||||
|
// deletion token in the Authorization header (constant-time compare). The
|
||||||
|
// ?token= query parameter is NOT accepted (#143): URL-carried tokens leak
|
||||||
|
// into access logs and browser history. Without a token, or with a wrong
|
||||||
|
// token, the paste must survive and the response must be 403.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestPaste creates a paste via the API and returns id + deletion token.
|
||||||
|
func createTestPaste(t *testing.T, h http.Handler) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"delete me"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeletionToken string `json:"deletion_token"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
if created.ID == "" || created.DeletionToken == "" {
|
||||||
|
t.Fatalf("create response missing id/deletion_token: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
return created.ID, created.DeletionToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func pasteExists(t *testing.T, h http.Handler, id string) bool {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code == 200 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if rec.Code == 404 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.Fatalf("get after delete: got %d", rec.Code)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteWithoutTokenForbidden(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id, _ := createTestPaste(t, h)
|
||||||
|
|
||||||
|
rec := doReq(t, h, "DELETE", "/api/pastes/"+id, "", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("delete without token: got %d want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if !pasteExists(t, h, id) {
|
||||||
|
t.Fatal("paste was deleted without a token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteWithWrongTokenForbidden(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id, _ := createTestPaste(t, h)
|
||||||
|
|
||||||
|
// query param: even the CORRECT token must be rejected now (#143)
|
||||||
|
id2, tok2 := createTestPaste(t, h)
|
||||||
|
rec := doReq(t, h, "DELETE", "/api/pastes/"+id2+"?token="+tok2, "", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("delete with correct token in query: got %d want 403 (#143)", rec.Code)
|
||||||
|
}
|
||||||
|
if !pasteExists(t, h, id2) {
|
||||||
|
t.Fatal("paste was deleted via ?token= query param (#143 regression)")
|
||||||
|
}
|
||||||
|
// header
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("delete with wrong token (header): got %d want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if !pasteExists(t, h, id) {
|
||||||
|
t.Fatal("paste was deleted with a wrong token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteWithCorrectToken(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id, tok := createTestPaste(t, h)
|
||||||
|
|
||||||
|
// via Authorization header
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("delete with correct token (header): got %d want 200: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if pasteExists(t, h, id) {
|
||||||
|
t.Fatal("paste still exists after authorized delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// query param: even with the correct token the delete must fail (#143)
|
||||||
|
id, tok = createTestPaste(t, h)
|
||||||
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"?token="+tok, "", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("delete with correct token (query): got %d want 403 (#143)", rec.Code)
|
||||||
|
}
|
||||||
|
if !pasteExists(t, h, id) {
|
||||||
|
t.Fatal("paste was deleted via ?token= query param (#143 regression)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #143: the deletion token must be accepted via the Authorization header on
|
||||||
|
// the redeem (hard delete) endpoint too.
|
||||||
|
func TestRedeemWithCorrectTokenHeader(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id, tok := createTestPaste(t, h)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/pastes/"+id+"/redeem", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("redeem with correct token (header): got %d want 200: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if pasteExists(t, h, id) {
|
||||||
|
t.Fatal("paste still exists after authorized redeem")
|
||||||
|
}
|
||||||
|
|
||||||
|
// query param must NOT work on redeem either
|
||||||
|
id, tok = createTestPaste(t, h)
|
||||||
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"/redeem?token="+tok, "", "")
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("redeem via ?token= query: got %d want 400 (#143)", rec.Code)
|
||||||
|
}
|
||||||
|
if !pasteExists(t, h, id) {
|
||||||
|
t.Fatal("paste was hard-deleted via ?token= query param (#143 regression)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteByCreatorViewerCookieStillAllowed(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
// create from a specific browser
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"mine"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator-abc"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
// creator browser deletes without a token: allowed (#37 /mine delete button)
|
||||||
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, "creator-abc", "")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("creator delete: got %d want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a different browser is still forbidden
|
||||||
|
id, _ := createTestPaste(t, h)
|
||||||
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+id, "someone-else", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("other browser delete: got %d want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if !pasteExists(t, h, id) {
|
||||||
|
t.Fatal("paste deleted by unrelated browser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletionAuthorizationExtract(t *testing.T) {
|
||||||
|
mk := func(hdr, q string) *http.Request {
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/pastes/x"+q, nil)
|
||||||
|
if hdr != "" {
|
||||||
|
req.Header.Set("Authorization", hdr)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
hdr, q, want string
|
||||||
|
}{
|
||||||
|
{"", "", ""},
|
||||||
|
{"Bearer tok", "", "tok"},
|
||||||
|
{"bearer tok", "", "tok"},
|
||||||
|
{"Token tok", "", "tok"},
|
||||||
|
{"tok", "", "tok"},
|
||||||
|
{"", "?token=q", ""}, // #143: query tokens are never accepted
|
||||||
|
{"Bearer hdr", "?token=q", "hdr"}, // header only
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := deletionAuthorization(mk(c.hdr, c.q)); got != c.want {
|
||||||
|
t.Errorf("deletionAuthorization(hdr=%q q=%q) = %q want %q", c.hdr, c.q, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"palette/internal/lang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGuessLang serves POST /api/guess-language.
|
||||||
|
func (a *apiServer) handleGuessLang(w http.ResponseWriter, r *http.Request) {
|
||||||
|
setRateLimitHeaders(w, 1, 5)
|
||||||
|
if !rateLimitGuess(r) {
|
||||||
|
writeRateLimited(w, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l := lang.GuessLang(req.Content)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"language": l})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #105: every validation path on create returns a machine-readable `code`
|
||||||
|
// alongside the human `error` message, so the new-page JS can map known
|
||||||
|
// codes to plain-language guidance.
|
||||||
|
|
||||||
|
func decodeErr(t *testing.T, rec *httptest.ResponseRecorder) (status int, errMsg, code string) {
|
||||||
|
t.Helper()
|
||||||
|
var got struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("bad json: %v (%s)", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return rec.Code, got.Error, got.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func postCreate(h http.Handler, body string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCodeContentEmpty(t *testing.T) {
|
||||||
|
h := testServer(t).routes()
|
||||||
|
_, _, code := decodeErr(t, postCreate(h, `{"content":" "}`))
|
||||||
|
if code != "content_empty" {
|
||||||
|
t.Fatalf("code = %q, want content_empty", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCodeContentTooLarge(t *testing.T) {
|
||||||
|
h := testServer(t).routes()
|
||||||
|
big := strings.Repeat("x", 6*1024*1024) // over the 5MB test cap
|
||||||
|
_, _, code := decodeErr(t, postCreate(h, `{"content":"`+big+`"}`))
|
||||||
|
if code != "content_too_large" {
|
||||||
|
t.Fatalf("code = %q, want content_too_large", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCodeSlugTaken(t *testing.T) {
|
||||||
|
h := testServer(t).routes()
|
||||||
|
if rec := postCreate(h, `{"content":"a","custom_slug":"taken-slug"}`); rec.Code != 201 {
|
||||||
|
t.Fatalf("seed create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
_, _, code := decodeErr(t, postCreate(h, `{"content":"b","custom_slug":"taken-slug"}`))
|
||||||
|
if code != "slug_taken" {
|
||||||
|
t.Fatalf("code = %q, want slug_taken", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCodeSlugInvalid(t *testing.T) {
|
||||||
|
h := testServer(t).routes()
|
||||||
|
_, _, code := decodeErr(t, postCreate(h, `{"content":"a","custom_slug":"bad slug!"}`))
|
||||||
|
if code != "slug_invalid" {
|
||||||
|
t.Fatalf("code = %q, want slug_invalid", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCodeExpiryInvalid(t *testing.T) {
|
||||||
|
h := testServer(t).routes()
|
||||||
|
_, _, code := decodeErr(t, postCreate(h, `{"content":"a","expires_in":"2s"}`))
|
||||||
|
if code != "expiry_invalid" {
|
||||||
|
t.Fatalf("code = %q, want expiry_invalid", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCodeRateLimited(t *testing.T) {
|
||||||
|
h := testServer(t).routes()
|
||||||
|
var code string
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
_, _, code = decodeErr(t, postCreate(h, `{"content":"a"}`))
|
||||||
|
if code == "rate_limited" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("never got rate_limited, last code = %q", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorCodeCanValidation(t *testing.T) {
|
||||||
|
h := testServer(t).routes()
|
||||||
|
|
||||||
|
post := func(fields map[string]string) *httptest.ResponseRecorder {
|
||||||
|
var b strings.Builder
|
||||||
|
for k, v := range fields {
|
||||||
|
b.WriteString("--B\r\nContent-Disposition: form-data; name=\"" + k + "\"\r\n\r\n" + v + "\r\n")
|
||||||
|
}
|
||||||
|
b.WriteString("--B--\r\n")
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes/can", strings.NewReader(b.String()))
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary=B")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// bad visibility
|
||||||
|
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "visibility": "nope"})); code != "invalid_visibility" {
|
||||||
|
t.Fatalf("visibility code = %q, want invalid_visibility", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid expiry
|
||||||
|
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "expires_in": "1s"})); code != "expiry_invalid" {
|
||||||
|
t.Fatalf("expiry code = %q, want expiry_invalid", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug taken on can create
|
||||||
|
if rec := postCreate(h, `{"content":"a","custom_slug":"can-slug"}`); rec.Code != 201 {
|
||||||
|
t.Fatalf("seed create: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "custom_slug": "can-slug", "json_items": `[{"title":"a","content":"b"}]`})); code != "slug_taken" {
|
||||||
|
t.Fatalf("can slug code = %q, want slug_taken", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// #138: the vwr viewer cookie must carry the Secure attribute. Tests inspect
|
||||||
|
// the Set-Cookie header directly rather than relying on cookie round-tripping,
|
||||||
|
// because Go's HTTP client (and browsers) drop Secure cookies over plain HTTP,
|
||||||
|
// which is how tests and local dev run.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer138(t *testing.T) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ui, err := web.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
|
||||||
|
ss := NewTestSettingsStore(t, cfg)
|
||||||
|
globalSettingsFn = ss.get
|
||||||
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
|
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
|
req := httptest.NewRequest("GET", "/history", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.routes().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewerCookieAttributes(t *testing.T) {
|
||||||
|
rec := newTestServer138(t)
|
||||||
|
var vwr *string
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "vwr" {
|
||||||
|
cc := c
|
||||||
|
vwr = &cc.Value
|
||||||
|
if !c.Secure {
|
||||||
|
t.Error("vwr cookie missing Secure attribute (#138)")
|
||||||
|
}
|
||||||
|
if !c.HttpOnly {
|
||||||
|
t.Error("vwr cookie missing HttpOnly attribute")
|
||||||
|
}
|
||||||
|
if c.Path != "/" {
|
||||||
|
t.Errorf("vwr cookie Path = %q, want /", c.Path)
|
||||||
|
}
|
||||||
|
if c.SameSite != 2 { // http.SameSiteLaxMode
|
||||||
|
t.Errorf("vwr cookie SameSite = %v, want Lax", c.SameSite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vwr == nil {
|
||||||
|
t.Fatal("no vwr cookie set")
|
||||||
|
}
|
||||||
|
// also confirm the raw header form spells out Secure
|
||||||
|
sc := rec.Header().Get("Set-Cookie")
|
||||||
|
if !strings.Contains(sc, "Secure") {
|
||||||
|
t.Errorf("Set-Cookie header %q lacks Secure", sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// #235: Type column. Text pastes show the language, attachment pastes show
|
||||||
|
// the file extension (lowercase, no dot).
|
||||||
|
func TestDisplayType(t *testing.T) {
|
||||||
|
lang := "python"
|
||||||
|
cases := []struct {
|
||||||
|
lang *string
|
||||||
|
att string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{nil, "", "text"},
|
||||||
|
{&lang, "", "python"},
|
||||||
|
{&lang, "report.pdf", "pdf"},
|
||||||
|
{nil, "photo.PNG", "png"},
|
||||||
|
{&lang, "archive.tar.gz", "gz"},
|
||||||
|
{&lang, "noext", "python"}, // no extension: fall back to language
|
||||||
|
{&lang, ".hidden", "python"}, // dotfile: no extension
|
||||||
|
{&lang, "dir/name.txt", "txt"}, // path component only
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := displayType(c.lang, c.att); got != c.want {
|
||||||
|
t.Errorf("displayType(%v, %q) = %q, want %q", c.lang, c.att, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttachmentExtName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"a.txt": "txt", "A.PNG": "png", "noext": "", ".hidden": "",
|
||||||
|
"x.": "", "dir/b.md": "md", "": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := attachmentExtName(in); got != want {
|
||||||
|
t.Errorf("attachmentExtName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Regression tests for #68 input-validation gaps: negative/oversized content
|
||||||
|
// lengths (413), limit=0 → default page size, unchecked query params
|
||||||
|
// (negative offset), and negative burn_after_reads.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createPasteRaw(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request whose decoded content exceeds the admin-tunable cap is rejected
|
||||||
|
// with 413 and a clear message (content under the body cap, over the
|
||||||
|
// content cap).
|
||||||
|
func TestCreatePasteContentOverCap413(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
// content over MaxContentBytes (5MiB) but under body cap (+4KiB): send
|
||||||
|
// just over the content cap so the per-field check fires first.
|
||||||
|
content := strings.Repeat("a", 5*1024*1024+10)
|
||||||
|
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s"}`, content))
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "content exceeds max") {
|
||||||
|
t.Fatalf("unclear error message: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request whose entire body is larger than the server-side body cap is cut
|
||||||
|
// off by http.MaxBytesReader and answered with 413, not decoded into memory
|
||||||
|
// (#68: previously a giant body was fully buffered, then rejected only at
|
||||||
|
// the per-field check — actually the decode happened before any check).
|
||||||
|
func TestCreatePasteBodyOverCap413(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
// shrink the content cap so the body cap is small too
|
||||||
|
ss := s.settings.get()
|
||||||
|
ss.MaxContentBytes = 64 * 1024
|
||||||
|
if err := s.settings.set(ss); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
content := strings.Repeat("a", 200*1024) // 200KiB > 64KiB+4KiB body cap
|
||||||
|
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s","title":"x"}`, content))
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative content lengths cannot be expressed via JSON, but a negative
|
||||||
|
// expires-style numeric payload must not crash; more importantly the
|
||||||
|
// burn_after_reads field: negative values are rejected with a clear message.
|
||||||
|
func TestCreatePasteNegativeBurnAfterReads(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
rec := createPasteRaw(t, h, `{"content":"hi","burn_after_reads":-5}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "burn_after_reads") {
|
||||||
|
t.Fatalf("unclear error message: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 and positive values still work (0 = default single read, per #49)
|
||||||
|
rec = createPasteRaw(t, h, `{"content":"hi","burn_after_reads":0,"burn_after_read":true}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("zero burn_after_reads: got %d want 201: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit=0 on list endpoints returns the default page size (existing clamp
|
||||||
|
// treats <=0 as default; #68 asks this be explicit and tested).
|
||||||
|
func TestListLimitZeroUsesDefault(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
// seed 3 public pastes
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("seed: got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range []string{"/api/public?limit=0", "/api/public"} {
|
||||||
|
req := httptest.NewRequest("GET", q, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("%s: got %d", q, rec.Code)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []struct{ ID string } `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if got.Limit != 25 || len(got.Items) != 3 {
|
||||||
|
t.Fatalf("%s: limit=%d items=%d, want limit 25 and all 3 items", q, got.Limit, len(got.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Huge limit values are clamped to the max page size (already the behavior;
|
||||||
|
// regression-tested here per #68 "validate unchecked params").
|
||||||
|
func TestListLimitHugeClamped(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/public?limit=999999999", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var got struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if got.Limit != 25 {
|
||||||
|
t.Fatalf("limit=%d, want clamped to 25", got.Limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative offset previously passed through unchecked to SQL (harmless in
|
||||||
|
// SQLite, but invalid); it must be clamped to 0 (#68).
|
||||||
|
func TestListNegativeOffsetClamped(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("seed: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/public?offset=-999", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if got.Offset != 0 || got.Total != 2 {
|
||||||
|
t.Fatalf("offset=%d total=%d, want offset 0 and total 2", got.Offset, got.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/mine too (needs the viewer cookie)
|
||||||
|
req = httptest.NewRequest("GET", "/api/mine?offset=-5", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "offclamp"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var mine struct {
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &mine)
|
||||||
|
if mine.Offset != 0 {
|
||||||
|
t.Fatalf("mine offset=%d, want 0", mine.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-numeric limit/offset fall back to defaults instead of 500s.
|
||||||
|
func TestListGarbageParams(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/public?limit=abc&offset=xyz", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if got.Limit != 25 || got.Offset != 0 {
|
||||||
|
t.Fatalf("limit=%d offset=%d, want 25/0", got.Limit, got.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// #81: ALL password verification attempts (GET query param, header, POST
|
||||||
|
// form) must go through the per-IP unlock limiter. Regression: N wrong
|
||||||
|
// passwords via X-Paste-Password must eventually yield 429.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createPasswordPaste(t *testing.T, s *apiServer, pw string) string {
|
||||||
|
t.Helper()
|
||||||
|
h := s.routes()
|
||||||
|
body := `{"content":"secret","password":"` + pw + `"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
return created.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitGetPasswordQuery: repeated wrong passwords via GET
|
||||||
|
// X-Paste-Password wrong attempts must eventually return 429 (unlock limiter: burst 5).
|
||||||
|
func TestRateLimitGetPasswordQuery(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createPasswordPaste(t, s, "hunter2")
|
||||||
|
|
||||||
|
var saw429 bool
|
||||||
|
// more attempts than the unlock burst (5)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code == 429 {
|
||||||
|
saw429 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !saw429 {
|
||||||
|
t.Fatal("expected 429 after repeated wrong password attempts, never got one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitGetPasswordHeader: same guarantee for the X-Paste-Password header path.
|
||||||
|
func TestRateLimitGetPasswordHeader(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createPasswordPaste(t, s, "hunter2")
|
||||||
|
|
||||||
|
var saw429 bool
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code == 429 {
|
||||||
|
saw429 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rec.Code != 401 {
|
||||||
|
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !saw429 {
|
||||||
|
t.Fatal("expected 429 after repeated wrong header password attempts, never got one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitGetPasswordCorrectStillAllowed: a correct password must still
|
||||||
|
// work within the burst (the limiter gates attempts, not correctness).
|
||||||
|
func TestRateLimitGetPasswordCorrectStillAllowed(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
id := createPasswordPaste(t, s, "hunter2")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "hunter2")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("expected 200 for correct password within burst, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Regression tests for #86: title and language are bounded at create time.
|
||||||
|
// Titles over 200 chars are truncated; language must match
|
||||||
|
// ^[a-zA-Z0-9+#-]{1,40}$ or the create is rejected with a clear 400.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pasteMeta fetches a created paste's stored metadata via the API.
|
||||||
|
func pasteMeta(t *testing.T, h http.Handler, id string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/pastes/"+id, nil))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("get paste %s: got %d: %s", id, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 5000-char title is truncated to 200 characters at create time (#86).
|
||||||
|
func TestCreatePasteTitleTruncated(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
title := strings.Repeat("t", 5000)
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "hi", "title": title})
|
||||||
|
rec := createPasteRaw(t, h, string(body))
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
meta := pasteMeta(t, h, resp.ID)
|
||||||
|
got, _ := meta["title"].(string)
|
||||||
|
if got != strings.Repeat("t", 200) {
|
||||||
|
t.Fatalf("title not truncated to 200 chars: len=%d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A title within the 200-char bound is stored verbatim (minus surrounding
|
||||||
|
// whitespace, which is trimmed).
|
||||||
|
func TestCreatePasteTitleWithinBoundKept(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
title := " " + strings.Repeat("x", 200) + " "
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "hi", "title": title})
|
||||||
|
rec := createPasteRaw(t, h, string(body))
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
meta := pasteMeta(t, h, resp.ID)
|
||||||
|
if got, _ := meta["title"].(string); got != strings.Repeat("x", 200) {
|
||||||
|
t.Fatalf("title changed unexpectedly: len=%d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A language longer than 40 chars is rejected with a clear 400 (#86).
|
||||||
|
func TestCreatePasteLanguageTooLong400(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "hi", "language": strings.Repeat("a", 41)})
|
||||||
|
rec := createPasteRaw(t, h, string(body))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "language") {
|
||||||
|
t.Fatalf("unclear error message: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language strings outside ^[a-zA-Z0-9+#-]{1,40}$ are rejected with 400.
|
||||||
|
func TestCreatePasteLanguageBadFormat400(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
for _, bad := range []string{
|
||||||
|
"<img src=x onerror=alert(1)>",
|
||||||
|
"java script",
|
||||||
|
"c++ extra!",
|
||||||
|
"py_thon",
|
||||||
|
"go.lang",
|
||||||
|
} {
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "hi", "language": bad})
|
||||||
|
rec := createPasteRaw(t, h, string(body))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("language %q: got %d want 400: %s", bad, rec.Code, rec.Body.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "language must match") {
|
||||||
|
t.Errorf("language %q: unclear error: %s", bad, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid languages (letters, digits, #, +, -) within 40 chars are accepted.
|
||||||
|
func TestCreatePasteLanguageValidAccepted(t *testing.T) {
|
||||||
|
for _, ok := range []string{"go", "c#", "f#", "c++", "objective-c", "ECMAScript-2023", strings.Repeat("a", 40)} {
|
||||||
|
s2 := testServer(t) // fresh rate limiter per case
|
||||||
|
h2 := s2.routes()
|
||||||
|
body, _ := json.Marshal(map[string]any{"content": "hi", "language": ok})
|
||||||
|
rec := createPasteRaw(t, h2, string(body))
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Errorf("language %q: got %d want 201: %s", ok, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absent or blank language still creates fine and stores NULL, and a
|
||||||
|
// blank title is stored NULL rather than an empty string.
|
||||||
|
func TestCreatePasteBlankMetadataOK(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"content":"hi"}`,
|
||||||
|
`{"content":"hi","language":"","title":" "}`,
|
||||||
|
} {
|
||||||
|
rec := createPasteRaw(t, h, body)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("body %s: got %d want 201: %s", body, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
meta := pasteMeta(t, h, resp.ID)
|
||||||
|
if lang, ok := meta["language"]; ok && lang != nil && lang != "" {
|
||||||
|
t.Fatalf("body %s: language not null: %v", body, lang)
|
||||||
|
}
|
||||||
|
if title, ok := meta["title"]; ok && title != nil && title != "" {
|
||||||
|
t.Fatalf("body %s: title not null: %v", body, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -11,11 +14,20 @@ import (
|
|||||||
|
|
||||||
func testServer(t *testing.T) *apiServer {
|
func testServer(t *testing.T) *apiServer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store, err := OpenStore(":memory:")
|
globalLimiter = newLimiter() // fresh buckets per test
|
||||||
|
ui, err := web.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return &apiServer{store: store, cfg: Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}}
|
st, err := store.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := Config{MaxTextBytes: 5 * 1024 * 1024, MaxItemBytes: 25 * 1024 * 1024}
|
||||||
|
ss := NewTestSettingsStore(t, cfg)
|
||||||
|
globalSettingsFn = ss.get
|
||||||
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
|
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAndGetPaste(t *testing.T) {
|
func TestCreateAndGetPaste(t *testing.T) {
|
||||||
@@ -30,7 +42,9 @@ func TestCreateAndGetPaste(t *testing.T) {
|
|||||||
if rec.Code != 201 {
|
if rec.Code != 201 {
|
||||||
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
|
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
var created struct{ ID string `json:"id"` }
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
if len(created.ID) != 6 {
|
if len(created.ID) != 6 {
|
||||||
t.Fatalf("unexpected id: %q", created.ID)
|
t.Fatalf("unexpected id: %q", created.ID)
|
||||||
@@ -61,7 +75,9 @@ func TestPasswordProtection(t *testing.T) {
|
|||||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
var created struct{ ID string `json:"id"` }
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
// without password -> 401
|
// without password -> 401
|
||||||
@@ -73,7 +89,8 @@ func TestPasswordProtection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// with password -> 200
|
// with password -> 200
|
||||||
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=hunter2", nil)
|
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "hunter2")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != 200 {
|
if rec.Code != 200 {
|
||||||
@@ -81,7 +98,8 @@ func TestPasswordProtection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wrong password -> 401
|
// wrong password -> 401
|
||||||
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=nope", nil)
|
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
|
||||||
|
req.Header.Set("X-Paste-Password", "nope")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != 401 {
|
if rec.Code != 401 {
|
||||||
@@ -121,10 +139,14 @@ func TestSoftDelete(t *testing.T) {
|
|||||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`))
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
var created struct{ ID string `json:"id"` }
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeletionToken string `json:"deletion_token"`
|
||||||
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil)
|
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+created.DeletionToken)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != 200 {
|
if rec.Code != 200 {
|
||||||
@@ -154,7 +176,7 @@ func TestListPublicExcludesUnlisted(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Items []map[string]any `json:"items"`
|
Items []map[string]any `json:"items"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
@@ -163,6 +185,46 @@ func TestListPublicExcludesUnlisted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListPublicExcludesPasswordAndUnlisted(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
bodies := []string{
|
||||||
|
`{"content":"open","visibility":"public"}`,
|
||||||
|
`{"content":"locked","visibility":"public","password":"hunter2"}`,
|
||||||
|
`{"content":"hidden","visibility":"unlisted"}`,
|
||||||
|
}
|
||||||
|
for _, body := range bodies {
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create %s: got %d", body, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/public", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list public: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Total != 1 || len(resp.Items) != 1 {
|
||||||
|
t.Fatalf("expected only the 1 public paste, got total=%d items=%d", resp.Total, len(resp.Items))
|
||||||
|
}
|
||||||
|
// password-protected and unlisted pastes must not appear (no metadata leak)
|
||||||
|
for _, secret := range []string{"hunter2", "locked", "hidden"} {
|
||||||
|
if strings.Contains(rec.Body.String(), secret) {
|
||||||
|
t.Fatalf("leaked %q in /api/public response", secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
h := s.routes()
|
h := s.routes()
|
||||||
@@ -170,19 +232,21 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
|||||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`))
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
var created struct{ ID string `json:"id"` }
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
s.store.SoftDelete(created.ID)
|
s.store.SoftDelete(created.ID)
|
||||||
|
|
||||||
// simulate grace elapsed
|
// simulate grace elapsed
|
||||||
past := time.Now().Unix() - (softDeleteGraceDays+1)*86400
|
past := time.Now().Unix() - (store.SoftDeleteGraceDays+1)*86400
|
||||||
s.store.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
|
s.store.Exec(`UPDATE pastes SET deleted_at=? WHERE id=?`, past, created.ID)
|
||||||
|
|
||||||
s.store.SweepExpired()
|
s.store.SweepExpired()
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&count)
|
count = s.store.QueryInt(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID)
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
t.Fatal("expected hard delete after grace period")
|
t.Fatal("expected hard delete after grace period")
|
||||||
}
|
}
|
||||||
@@ -190,9 +254,9 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
|
|||||||
|
|
||||||
func TestSlugCharset(t *testing.T) {
|
func TestSlugCharset(t *testing.T) {
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
s := genSlug(6)
|
s := store.GenSlug(6)
|
||||||
for _, c := range s {
|
for _, c := range s {
|
||||||
if !strings.ContainsRune(slugAlphabet, c) {
|
if !strings.ContainsRune(store.SlugAlphabet, c) {
|
||||||
t.Fatalf("bad char %q in slug %q", c, s)
|
t.Fatalf("bad char %q in slug %q", c, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +270,9 @@ func TestRawEndpoint(t *testing.T) {
|
|||||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`))
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
var created struct{ ID string `json:"id"` }
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
|
||||||
req = httptest.NewRequest("GET", "/raw/"+created.ID, nil)
|
req = httptest.NewRequest("GET", "/raw/"+created.ID, nil)
|
||||||
@@ -233,3 +299,27 @@ func TestNotFound(t *testing.T) {
|
|||||||
t.Fatalf("expected 404, got %d", rec.Code)
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #173: a missing paste ID on the UI route (/p/{id}, i.e. /{id} HTML view)
|
||||||
|
// should render the main UI page with a friendly "Paste ID not found"
|
||||||
|
// message, not a bare text 404. Status stays 404.
|
||||||
|
func TestPasteViewNotFoundFriendly(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
req := httptest.NewRequest("GET", "/zzzzzz", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 status, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "Paste ID not found") {
|
||||||
|
t.Fatalf("expected friendly message in body, got: %.200s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "<nav>") {
|
||||||
|
t.Fatalf("expected main UI chrome in body")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "404 page not found") {
|
||||||
|
t.Fatalf("body still contains bare Go 404 text")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// doReq performs a request against the router, carrying the given cookies,
|
||||||
|
// and returns the recorder (so Set-Cookie from the viewer middleware is visible).
|
||||||
|
func doReq(t *testing.T, h http.Handler, method, path, cookie string, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if cookie != "" {
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: cookie})
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewerCookieFor performs a request without the vwr cookie and extracts the
|
||||||
|
// one the viewer middleware sets in the response.
|
||||||
|
func viewerCookieFor(t *testing.T, h http.Handler, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := doReq(t, h, "GET", path, "", "")
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == "vwr" {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("vwr cookie not set")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMineCreateListDelete(t *testing.T) {
|
||||||
|
globalLimiter = newLimiter() // fresh rate-limit buckets
|
||||||
|
st, err := store.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ui, err := web.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
|
||||||
|
ss := NewTestSettingsStore(t, cfg)
|
||||||
|
globalSettingsFn = ss.get
|
||||||
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
|
a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
|
h := a.routes()
|
||||||
|
|
||||||
|
alice := viewerCookieFor(t, h, "/history")
|
||||||
|
if alice == "" {
|
||||||
|
t.Fatal("no viewer cookie issued")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create with alice's cookie -> stored viewer id
|
||||||
|
rec := doReq(t, h, "POST", "/api/pastes", alice, `{"content":"hello mine"}`)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created struct{ ID string }
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
if created.ID == "" {
|
||||||
|
t.Fatal("no id returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner sees it in /api/mine
|
||||||
|
rec = doReq(t, h, "GET", "/api/mine", alice, "")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("mine: %d", rec.Code)
|
||||||
|
}
|
||||||
|
var list struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []struct{ ID string `json:"id"` } `json:"items"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &list)
|
||||||
|
if list.Total != 1 || len(list.Items) != 1 || list.Items[0].ID != created.ID {
|
||||||
|
t.Fatalf("mine list: total=%d items=%v", list.Total, list.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a different browser's cookie does NOT see it
|
||||||
|
bob := viewerCookieFor(t, h, "/history")
|
||||||
|
rec = doReq(t, h, "GET", "/api/mine", bob, "")
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &list)
|
||||||
|
if list.Total != 0 {
|
||||||
|
t.Fatalf("other browser sees %d pastes, want 0", list.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete enforcement: bob cannot delete alice's paste
|
||||||
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, bob, "")
|
||||||
|
if rec.Code != 403 {
|
||||||
|
t.Fatalf("bob delete: %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner can delete
|
||||||
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, alice, "")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("alice delete: %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = doReq(t, h, "GET", "/api/mine", alice, "")
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &list)
|
||||||
|
if list.Total != 0 {
|
||||||
|
t.Fatalf("after delete, mine total=%d, want 0", list.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #34: the unlock cookie must be bound to the paste it unlocks, not a
|
||||||
|
// forgeable static value. A forged 'pw_<id>=1' cookie must not bypass the
|
||||||
|
// password check on the paste page.
|
||||||
|
func TestForgedUnlockCookieDoesNotBypassPassword(t *testing.T) {
|
||||||
|
globalLimiter = newLimiter()
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"SECRETPASTECONTENT","password":"hunter2"}`))
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||||
|
id := created.ID
|
||||||
|
|
||||||
|
// request the page with a forged unlock cookie in the old format
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "pw_" + id, Value: "1"})
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code == 200 && strings.Contains(rec.Body.String(), "SECRETPASTECONTENT") {
|
||||||
|
t.Fatal("forged pw_<id>=1 cookie bypassed password protection")
|
||||||
|
}
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Logf("forged-cookie request returned %d (page still locked) — good", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// test helpers for pentest tests (#34)
|
||||||
|
func jsonField(tb testing.TB, body, field string) string {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||||
|
tb.Fatalf("bad json: %v", err)
|
||||||
|
}
|
||||||
|
v, _ := m[field].(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #34: attacker-controlled content_type must not let a paste be served as
|
||||||
|
// HTML/SVG/XML from /raw (stored XSS). Only a fixed safe set passes through.
|
||||||
|
func TestRawRejectsHTMLContentType(t *testing.T) {
|
||||||
|
globalLimiter = newLimiter() // fresh rate-limit buckets
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
for _, ct := range []string{
|
||||||
|
"text/html", "TEXT/HTML", "text/html;charset=utf-8", "text/html;x=1",
|
||||||
|
"application/xhtml+xml", "image/svg+xml", "text/html,",
|
||||||
|
} {
|
||||||
|
globalLimiter = newLimiter() // burst 5, loop makes 7 creates
|
||||||
|
body := `{"content":"<script>alert(1)</script>","content_type":"` + ct + `"}`
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("ct %q: create got %d: %s", ct, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
id := jsonField(t, rec.Body.String(), "id")
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest("GET", "/raw/"+id, nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if got := rec.Header().Get("Content-Type"); got == ct {
|
||||||
|
t.Errorf("ct %q was served verbatim from /raw (stored XSS vector)", ct)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||||
|
t.Errorf("ct %q: /raw missing X-Content-Type-Options: nosniff", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawAllowsSafeContentType(t *testing.T) {
|
||||||
|
globalLimiter = newLimiter()
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
for _, ct := range []string{"text/plain", "image/png", "application/pdf", "application/octet-stream"} {
|
||||||
|
globalLimiter = newLimiter()
|
||||||
|
body := `{"content":"hi","content_type":"` + ct + `"}`
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("ct %q: create got %d", ct, rec.Code)
|
||||||
|
}
|
||||||
|
id := jsonField(t, rec.Body.String(), "id")
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req = httptest.NewRequest("GET", "/raw/"+id, nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != ct {
|
||||||
|
t.Errorf("ct %q: got %q", ct, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// #83 regression tests: `public` boolean in the create payload must map to
|
||||||
|
// visibility (false -> unlisted, true -> public); string `visibility` still works.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createPasteBody(t *testing.T, h *apiServer, body string) map[string]any {
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("create: got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("bad json: %v", err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVis(t *testing.T, h *apiServer, id string) string {
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("get %s: got %d", id, rec.Code)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("bad json: %v", err)
|
||||||
|
}
|
||||||
|
return resp.Visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicBooleanFalseMapsToUnlisted: {"public": false} must create an unlisted paste.
|
||||||
|
func TestPublicBooleanFalseMapsToUnlisted(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
resp := createPasteBody(t, s, `{"content":"x","public":false}`)
|
||||||
|
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
|
||||||
|
t.Fatalf("public:false -> got visibility %q, want unlisted", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicBooleanTrueMapsToPublic: {"public": true} must create a public paste.
|
||||||
|
func TestPublicBooleanTrueMapsToPublic(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
resp := createPasteBody(t, s, `{"content":"x","public":true}`)
|
||||||
|
if v := getVis(t, s, resp["id"].(string)); v != "public" {
|
||||||
|
t.Fatalf("public:true -> got visibility %q, want public", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicBooleanOverridesString: boolean wins when both fields are sent.
|
||||||
|
func TestPublicBooleanOverridesString(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
resp := createPasteBody(t, s, `{"content":"x","visibility":"public","public":false}`)
|
||||||
|
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
|
||||||
|
t.Fatalf("boolean override -> got %q, want unlisted", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVisibilityStringStillWorks: existing string contract unchanged.
|
||||||
|
func TestVisibilityStringStillWorks(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
resp := createPasteBody(t, s, `{"content":"x","visibility":"unlisted"}`)
|
||||||
|
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
|
||||||
|
t.Fatalf("string field -> got %q, want unlisted", v)
|
||||||
|
}
|
||||||
|
resp = createPasteBody(t, s, `{"content":"y","visibility":"public"}`)
|
||||||
|
if v := getVis(t, s, resp["id"].(string)); v != "public" {
|
||||||
|
t.Fatalf("string field -> got %q, want public", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicListExcludesPublicFalse: {"public":false} pastes stay out of /api/public.
|
||||||
|
func TestPublicListExcludesPublicFalse(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
createPasteBody(t, s, `{"content":"hidden","public":false}`)
|
||||||
|
req := httptest.NewRequest("GET", "/api/public", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.routes().ServeHTTP(rec, req)
|
||||||
|
var resp struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Total != 0 {
|
||||||
|
t.Fatalf("public:false paste leaked into /api/public: total=%d", resp.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-IP token bucket rate limiting (#2). Goroutine-safe via mutex.
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
tokens float64
|
||||||
|
last time.Time
|
||||||
|
rate float64 // tokens per second
|
||||||
|
burst float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]*bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLimiter() *limiter {
|
||||||
|
return &limiter{buckets: make(map[string]*bucket)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limiter) allow(key string, rate, burst float64) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
b, ok := l.buckets[key]
|
||||||
|
if !ok {
|
||||||
|
b = &bucket{tokens: burst, last: now, rate: rate, burst: burst}
|
||||||
|
l.buckets[key] = b
|
||||||
|
}
|
||||||
|
elapsed := now.Sub(b.last).Seconds()
|
||||||
|
b.tokens += elapsed * b.rate
|
||||||
|
if b.tokens > b.burst {
|
||||||
|
b.tokens = b.burst
|
||||||
|
}
|
||||||
|
b.last = now
|
||||||
|
if b.tokens < 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP extracts the client IP for rate-limit keying (#85).
|
||||||
|
//
|
||||||
|
// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
|
||||||
|
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
|
||||||
|
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
|
||||||
|
// proxy observed and is unspoofable by the client (a client-supplied fake
|
||||||
|
// entry only lands on the LEFT and is ignored). This matches chi's
|
||||||
|
// middleware.RealIP semantics for a single trusted proxy hop.
|
||||||
|
//
|
||||||
|
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
|
||||||
|
// reachable deployments must NOT expose the app to untrusted networks
|
||||||
|
// without a proxy in front, or attackers could forge the rightmost entry.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
if i := strings.LastIndex(xff, ","); i >= 0 {
|
||||||
|
return strings.TrimSpace(xff[i+1:])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(xff)
|
||||||
|
}
|
||||||
|
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
|
||||||
|
return strings.TrimSpace(xr)
|
||||||
|
}
|
||||||
|
host := r.RemoteAddr
|
||||||
|
if i := strings.LastIndex(host, ":"); i > 0 {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalLimiter = newLimiter()
|
||||||
|
|
||||||
|
// globalSettingsFn is set at startup; tests can point it at fixed settings.
|
||||||
|
var globalSettingsFn func() Settings
|
||||||
|
|
||||||
|
func globalSettings() Settings {
|
||||||
|
if globalSettingsFn != nil {
|
||||||
|
return globalSettingsFn()
|
||||||
|
}
|
||||||
|
return defaultSettings(Config{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitCreate uses the admin-tunable burst and per-minute refill (#40).
|
||||||
|
func rateLimitCreate(r *http.Request, s Settings) bool {
|
||||||
|
return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitGuess: 1 req/sec refill, burst 5, per IP.
|
||||||
|
func rateLimitGuess(r *http.Request) bool {
|
||||||
|
return globalLimiter.allow("guess:"+clientIP(r), 1, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitUnlock: 5 per minute per IP+paste.
|
||||||
|
func rateLimitUnlock(id string, r *http.Request) bool {
|
||||||
|
return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitAdmin: 5 attempts per minute per IP on the admin key check (#66),
|
||||||
|
// same pattern as the unlock limiter (#34).
|
||||||
|
func rateLimitAdmin(r *http.Request) bool {
|
||||||
|
return globalLimiter.allow("admin:"+clientIP(r), 5.0/60.0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRateLimited responds 429 with Retry-After based on refill rate.
|
||||||
|
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
|
||||||
|
writeErrCode(w, 429, "rate_limited", "rate limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
|
||||||
|
func setRateLimitHeaders(w http.ResponseWriter, limit, burst int) {
|
||||||
|
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
|
||||||
|
w.Header().Set("X-RateLimit-Burst", strconv.Itoa(burst))
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"palette/internal/lang"
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) *apiServer {
|
||||||
|
t.Helper()
|
||||||
|
globalLimiter = newLimiter() // fresh buckets per test
|
||||||
|
st, err := store.OpenStore(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ui, err := web.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := Config{MaxTextBytes: 1024 * 1024}
|
||||||
|
ss := NewTestSettingsStore(t, cfg)
|
||||||
|
globalSettingsFn = ss.get
|
||||||
|
t.Cleanup(func() { globalSettingsFn = nil })
|
||||||
|
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitCreateBurst: burst of 5 creates allowed, then 429.
|
||||||
|
func TestRateLimitCreateBurst(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
// unique IP per test run so tests don't share buckets
|
||||||
|
reqIP := "10.9.9.1:1234"
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 201 {
|
||||||
|
t.Fatalf("req %d: want 201, got %d: %s", i, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 429 {
|
||||||
|
t.Fatalf("6th req: want 429, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
if ra := rr.Header().Get("Retry-After"); ra == "" {
|
||||||
|
t.Fatal("missing Retry-After header")
|
||||||
|
}
|
||||||
|
if ra := rr.Header().Get("X-RateLimit-Limit"); ra == "" {
|
||||||
|
t.Fatal("missing X-RateLimit-Limit header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitRefill: after waiting >1s a token refills and a create succeeds.
|
||||||
|
func TestRateLimitRefill(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
reqIP := "10.9.9.2:1234"
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
}
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 201 {
|
||||||
|
t.Fatalf("after refill: want 201, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitGuess: guess-language endpoint is limited too.
|
||||||
|
func TestRateLimitGuess(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
reqIP := "10.9.9.3:1234"
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"def f(): pass"}`)))
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if i < 5 && rr.Code != 200 {
|
||||||
|
t.Fatalf("req %d: want 200, got %d", i, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"x"}`)))
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 429 {
|
||||||
|
t.Fatalf("want 429, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitUnlock: 5 unlock attempts per IP+paste per minute, then 429.
|
||||||
|
func TestRateLimitUnlock(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
// create a password-protected paste
|
||||||
|
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret", "password": "pw1", "visibility": "unlisted"})
|
||||||
|
if rr.Code != 201 {
|
||||||
|
t.Fatalf("create failed: %d", rr.Code)
|
||||||
|
}
|
||||||
|
var created map[string]any
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &created)
|
||||||
|
id := created["id"].(string)
|
||||||
|
reqIP := "10.9.9.4:1234"
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr2, req)
|
||||||
|
if i < 5 && rr2.Code == 429 {
|
||||||
|
t.Fatalf("req %d: unexpected 429", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.RemoteAddr = reqIP
|
||||||
|
rr2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr2, req)
|
||||||
|
if rr2.Code != 429 {
|
||||||
|
t.Fatalf("want 429, got %d", rr2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHighlightCode basic expectations.
|
||||||
|
func TestHighlightCode(t *testing.T) {
|
||||||
|
in := "func main() {\n\t// comment\n\tfmt.Println(\"hello\")\n}\n"
|
||||||
|
out := lang.HighlightCode(in, "go")
|
||||||
|
if !bytes.Contains([]byte(out), []byte(`<span class="tok-kw">func</span>`)) {
|
||||||
|
t.Fatalf("no keyword span: %s", out)
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(out), []byte(`<span class="tok-com">// comment</span>`)) {
|
||||||
|
t.Fatalf("no comment span: %s", out)
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(out), []byte(`tok-str">"hello"</span>`)) {
|
||||||
|
t.Fatalf("no string span: %s", out)
|
||||||
|
}
|
||||||
|
// unsupported language returns escaped plain text
|
||||||
|
plain := lang.HighlightCode("<b>x</b>", "text")
|
||||||
|
if plain != "<b>x</b>" {
|
||||||
|
t.Fatalf("plain escaping wrong: %q", plain)
|
||||||
|
}
|
||||||
|
// #205: newline join preserved as the delimiter paste-lines.js splits on;
|
||||||
|
// per-line segments survive and the client joins with '' so no newline
|
||||||
|
// text node reaches the rendered DOM.
|
||||||
|
hl := lang.HighlightCode("a\nb\nc", "go")
|
||||||
|
if got := len(splitLines(hl)); got != 3 {
|
||||||
|
t.Fatalf("want 3 lines, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, s[start:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreatorAutoUnlock: create with password, then POST the password to
|
||||||
|
// /{id}, then GET /{id} with the cookie shows the paste (#26).
|
||||||
|
func TestCreatorAutoUnlock(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret stuff", "password": "pw2", "visibility": "unlisted"})
|
||||||
|
if rr.Code != 201 {
|
||||||
|
t.Fatalf("create failed: %d", rr.Code)
|
||||||
|
}
|
||||||
|
var created map[string]any
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &created)
|
||||||
|
id := created["id"].(string)
|
||||||
|
|
||||||
|
// locked GET shows unlock page
|
||||||
|
req := httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
rr2 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr2, req)
|
||||||
|
if bytes.Contains(rr2.Body.Bytes(), []byte("secret stuff")) {
|
||||||
|
t.Fatal("locked paste leaked content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlock POST with ?next= should set cookie and redirect
|
||||||
|
req = httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=pw2&next=/"+id+"?created=1")))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr3 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr3, req)
|
||||||
|
if rr3.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unlock POST: want 303, got %d", rr3.Code)
|
||||||
|
}
|
||||||
|
var cookie *http.Cookie
|
||||||
|
for _, c := range rr3.Result().Cookies() {
|
||||||
|
if c.Name == "pw_"+id {
|
||||||
|
cookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cookie == nil {
|
||||||
|
t.Fatal("no pw_ cookie set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET with cookie shows content
|
||||||
|
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr4 := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr4, req)
|
||||||
|
if !bytes.Contains(rr4.Body.Bytes(), []byte("secret stuff")) {
|
||||||
|
t.Fatalf("cookie unlock failed: %d %s", rr4.Code, rr4.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
|
||||||
|
// (appended by the trusted Traefik proxy), never the raw/leftmost header
|
||||||
|
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
|
||||||
|
// limit or rotate buckets.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientIPTakesRightmostXFF(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
|
||||||
|
if got := clientIP(r); got != "203.0.113.9" {
|
||||||
|
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPXRealIPFallback(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.RemoteAddr = "10.42.0.7:51000"
|
||||||
|
r.Header.Set("X-Real-Ip", "203.0.113.10")
|
||||||
|
if got := clientIP(r); got != "203.0.113.10" {
|
||||||
|
t.Fatalf("clientIP = %q, want 203.0.113.10", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPDirectFallback(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.RemoteAddr = "198.51.100.5:51000"
|
||||||
|
if got := clientIP(r); got != "198.51.100.5" {
|
||||||
|
t.Fatalf("clientIP = %q, want 198.51.100.5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
|
||||||
|
// leftmost XFF entry stays limited on their real (rightmost) IP.
|
||||||
|
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
|
req.RemoteAddr = "10.42.0.7:51000"
|
||||||
|
// each request spoofs a DIFFERENT leftmost entry
|
||||||
|
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 201 {
|
||||||
|
t.Fatalf("req %d: want 201, got %d", i, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 6th request, still the same real IP, new spoofed prefix: must 429
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
|
req.RemoteAddr = "10.42.0.7:51000"
|
||||||
|
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 429 {
|
||||||
|
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spoofN(i int) string {
|
||||||
|
return "1.2.3." + string(rune('0'+i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct real IPs must still get distinct buckets (no over-limiting).
|
||||||
|
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
h := srv.routes()
|
||||||
|
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
|
req.RemoteAddr = "10.42.0.7:51000"
|
||||||
|
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != 201 {
|
||||||
|
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
// Package api implements palette's REST handlers and the HTTP router:
|
||||||
|
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
langpkg "palette/internal/lang"
|
||||||
|
"palette/internal/store"
|
||||||
|
"palette/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DBPath string
|
||||||
|
MaxTextBytes int64
|
||||||
|
MaxItemBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiServer struct {
|
||||||
|
store *store.Store
|
||||||
|
cfg Config
|
||||||
|
ui *web.UI
|
||||||
|
settings *settingsStore
|
||||||
|
adminKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
|
||||||
|
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeErrCode emits a JSON error with a machine-readable code (#105): the
|
||||||
|
// new-page JS maps known codes to plain-language messages, API consumers get
|
||||||
|
// the stable `code` field alongside the human `error` text.
|
||||||
|
func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg, "code": code})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
|
||||||
|
// code (#105).
|
||||||
|
func createErrCode(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrSlugTaken):
|
||||||
|
return "slug_taken"
|
||||||
|
case errors.Is(err, store.ErrReservedSlug):
|
||||||
|
return "slug_reserved"
|
||||||
|
case errors.Is(err, store.ErrInvalidSlug):
|
||||||
|
return "slug_invalid"
|
||||||
|
case strings.Contains(err.Error(), "expires_in"):
|
||||||
|
return "expiry_invalid"
|
||||||
|
default:
|
||||||
|
return "invalid_request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes returns the HTTP handler for the server.
|
||||||
|
func (a *apiServer) Routes() http.Handler {
|
||||||
|
return a.routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) routes() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
|
r.Use(a.limitRequestBody) // #68: hard server-side body cap -> 413
|
||||||
|
r.Use(viewerCookieMiddleware)
|
||||||
|
r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
|
||||||
|
|
||||||
|
// admin (#40): HTML page is open (key entry via form); API is key-guarded
|
||||||
|
r.Get("/admin", a.ui.Handlers().HandleAdminPage)
|
||||||
|
r.Get("/admin/api/settings", a.adminAuth(a.handleAdminGetSettings, a.adminKey))
|
||||||
|
r.Post("/admin/api/settings", a.adminAuth(a.handleAdminPostSettings, a.adminKey))
|
||||||
|
|
||||||
|
// API
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Post("/pastes", a.handleCreatePaste)
|
||||||
|
r.Get("/pastes/{id}", a.handleGetPaste)
|
||||||
|
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
||||||
|
r.Get("/mine", a.handleListMine)
|
||||||
|
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
||||||
|
r.Get("/public", a.handleListPublic)
|
||||||
|
r.Post("/guess-language", a.handleGuessLang)
|
||||||
|
r.Post("/pastes/can", a.handleCreateCan)
|
||||||
|
r.Get("/cans/{id}", a.handleGetCan)
|
||||||
|
r.Delete("/cans/{id}", a.handleDeleteCan)
|
||||||
|
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
||||||
|
})
|
||||||
|
|
||||||
|
// can page (#4): GET renders, POST unlocks (same flow as pastes)
|
||||||
|
r.Get("/can/{id}", a.handleCanPage)
|
||||||
|
r.Post("/can/{id}", a.handleCanPage)
|
||||||
|
|
||||||
|
// raw + #38 attachment serving
|
||||||
|
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
|
||||||
|
r.Get("/raw/{id}", a.handleRaw)
|
||||||
|
|
||||||
|
// web pages
|
||||||
|
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
|
||||||
|
r.Get("/new", a.ui.Handlers().HandleNewPage)
|
||||||
|
r.Get("/history", a.ui.Handlers().HandleHistoryPage)
|
||||||
|
r.Get("/settings", a.ui.Handlers().HandleSettingsPage)
|
||||||
|
r.Get("/mine", a.ui.Handlers().HandleMinePage)
|
||||||
|
r.Handle("/static/*", a.ui.StaticHandler())
|
||||||
|
r.Get("/unlock/{id}", a.handlePasteView)
|
||||||
|
r.Post("/unlock/{id}", a.handlePasteView)
|
||||||
|
r.Get("/{id}", a.handlePasteView)
|
||||||
|
r.Post("/{id}", a.handlePasteView)
|
||||||
|
|
||||||
|
// #173: missing paste URLs render the main UI with a friendly not-found
|
||||||
|
// message instead of a bare JSON 404. Known routes (above) handle real pages;
|
||||||
|
// anything else is a nonexistent paste ID or typo.
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.webHandlers().HandleNotFoundPage(w, r)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewerCookieMiddleware ensures every request carries an anonymous browser id
|
||||||
|
// cookie ("vwr"); sets one on the response if absent. Used by /mine (#37, #49).
|
||||||
|
func viewerCookieMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie("vwr"); err != nil || c.Value == "" {
|
||||||
|
id := store.GenSlug(16)
|
||||||
|
// #138: Secure keeps the viewer id off plain-HTTP requests
|
||||||
|
// (all deployments are HTTPS-only behind traefik).
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "vwr", Value: id, Path: "/",
|
||||||
|
MaxAge: 31536000, HttpOnly: true, Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
r.AddCookie(&http.Cookie{Name: "vwr", Value: id})
|
||||||
|
// remember that this cookie was minted here, not sent by the client
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), vwrMintedKey, true))
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type vwrMintedKeyType struct{}
|
||||||
|
|
||||||
|
var vwrMintedKey vwrMintedKeyType
|
||||||
|
|
||||||
|
func currentViewerID(r *http.Request) string {
|
||||||
|
if c, err := r.Cookie("vwr"); err == nil {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewerSentCookie reports whether the client itself sent a vwr cookie
|
||||||
|
// (as opposed to the middleware minting one for this request).
|
||||||
|
func viewerSentCookie(r *http.Request) bool {
|
||||||
|
if _, err := r.Cookie("vwr"); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, minted := r.Context().Value(vwrMintedKey).(bool)
|
||||||
|
return !minted
|
||||||
|
}
|
||||||
|
|
||||||
|
// #143: hand the deletion token to the creator's browser via a short-lived
|
||||||
|
// HttpOnly cookie instead of the URL. The paste view reads it once to show
|
||||||
|
// the one-time created banner; it expires after 60s.
|
||||||
|
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "tok_" + pasteID, Value: token, Path: "/",
|
||||||
|
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.settings.get()
|
||||||
|
setRateLimitHeaders(w, 1, 5)
|
||||||
|
if !rateLimitCreate(r, s) {
|
||||||
|
writeRateLimited(w, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
a.handleCreatePasteMultipart(w, r, s) // #38: file attachment create
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p store.Paste
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
|
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
|
||||||
|
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, 400, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
|
||||||
|
if status == http.StatusRequestEntityTooLarge {
|
||||||
|
writeErrCode(w, status, "content_too_large", msg)
|
||||||
|
} else {
|
||||||
|
writeErrCode(w, status, "content_empty", msg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #86: bound free-form metadata at create time
|
||||||
|
if p.Title != nil {
|
||||||
|
t, err := checkTitle(*p.Title)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Title = &t
|
||||||
|
}
|
||||||
|
if p.Language != nil {
|
||||||
|
l, err := checkLanguage(*p.Language)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l == "" {
|
||||||
|
p.Language = nil
|
||||||
|
} else {
|
||||||
|
p.Language = &l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.BurnAfterReads != nil { // #68: reject negative read budgets
|
||||||
|
if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #40: admin-configurable default expiry
|
||||||
|
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
|
||||||
|
def := s.DefaultExpiry
|
||||||
|
p.ExpiresIn = &def
|
||||||
|
}
|
||||||
|
p.ViewerID = currentViewerID(r)
|
||||||
|
created, err := a.store.CreatePaste(&p)
|
||||||
|
if err != nil {
|
||||||
|
writeErrCode(w, 400, createErrCode(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143
|
||||||
|
writeJSON(w, 201, map[string]any{
|
||||||
|
"id": created.ID,
|
||||||
|
"deletion_token": created.DeletionToken,
|
||||||
|
"url": "/" + created.ID,
|
||||||
|
"raw_url": "/raw/" + created.ID,
|
||||||
|
"api_url": "/api/pastes/" + created.ID,
|
||||||
|
"expires_at": created.ExpiresAt,
|
||||||
|
"created_at": created.CreatedAt,
|
||||||
|
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
row, err := a.store.GetPaste(id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
|
writeErr(w, 404, "paste expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Burned() { // #49: read budget exhausted
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.PasswordHash.Valid {
|
||||||
|
// #81/#141: every password verification (header or empty) goes
|
||||||
|
// through the same per-IP+paste unlock limiter as the POST form
|
||||||
|
// path, so brute-force via X-Paste-Password gets 429. The
|
||||||
|
// ?password= query fallback was removed (#141): query strings
|
||||||
|
// leak into access logs, browser history, and Referer headers.
|
||||||
|
if !rateLimitUnlock(row.ID, r) {
|
||||||
|
writeRateLimited(w, 60)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// require password via header or query
|
||||||
|
pw := r.Header.Get("X-Paste-Password")
|
||||||
|
if pw == "" || !store.CheckPassword(row.PasswordHash.String, pw) {
|
||||||
|
writeErr(w, 401, "password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #58: only the reader that wins the atomic burn claim may see content.
|
||||||
|
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||||
|
if !admitted {
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{
|
||||||
|
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
||||||
|
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
|
||||||
|
"type": a.pasteTypeLabel(row),
|
||||||
|
"view_count": row.ViewCount, "visibility": row.Visibility,
|
||||||
|
"reads_remaining": rem,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pasteTypeLabel computes the Type value for a single paste (#235): file
|
||||||
|
// extension for attachment pastes, else the stored language, else "text".
|
||||||
|
func (a *apiServer) pasteTypeLabel(row *store.PasteRow) string {
|
||||||
|
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
|
||||||
|
if ext := attachmentExtName(att.Filename); ext != "" {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !row.Language.Valid || row.Language.String == "" {
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
return row.Language.String
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
row, err := a.store.GetPaste(id)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #63: deletion requires authorization. Either the deletion token issued
|
||||||
|
// at create time (Authorization header; #143 removed the ?token= query
|
||||||
|
// path so the bearer secret never lands in access logs or history), or
|
||||||
|
// the creator browser itself (client-sent vwr cookie matching the
|
||||||
|
// paste's viewer, #37).
|
||||||
|
if !a.deletionAuthorized(r, row) {
|
||||||
|
writeErr(w, 403, "deletion token required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.store.SoftDelete(row.ID); err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletionAuthorization extracts the deletion token from the request:
|
||||||
|
// the Authorization header ("Bearer <t>", "Token <t>", or a bare token).
|
||||||
|
// The ?token= query parameter is deliberately NOT accepted (#143): URL
|
||||||
|
// query strings end up in proxy access logs and browser history. Returns
|
||||||
|
// "" when absent.
|
||||||
|
func deletionAuthorization(r *http.Request) string {
|
||||||
|
if h := r.Header.Get("Authorization"); h != "" {
|
||||||
|
for _, prefix := range []string{"Bearer ", "Token "} {
|
||||||
|
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
|
||||||
|
return strings.TrimSpace(h[len(prefix):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletionAuthorized reports whether the request may soft-delete the paste:
|
||||||
|
// a valid constant-time-matched deletion token, or the creator browser's
|
||||||
|
// viewer cookie (#37). Plain API clients with no token get false.
|
||||||
|
func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) bool {
|
||||||
|
if tok := deletionAuthorization(r); tok != "" {
|
||||||
|
return row.DeletionToken.Valid && row.DeletionToken.String != "" &&
|
||||||
|
store.DeletionTokenEqual(row.DeletionToken.String, tok)
|
||||||
|
}
|
||||||
|
// viewer-cookie delete enforcement (#37): only the browser that created
|
||||||
|
// the paste (matching vwr) may delete it via this endpoint. Requests with
|
||||||
|
// no client-sent vwr cookie (plain API clients) are unaffected.
|
||||||
|
vid := currentViewerID(r)
|
||||||
|
return vid != "" && viewerSentCookie(r) && row.ViewerID.Valid &&
|
||||||
|
row.ViewerID.String != "" && row.ViewerID.String == vid
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayType returns the Type-column value for a list row (#235): the file
|
||||||
|
// extension for attachment pastes, otherwise the detected language (default
|
||||||
|
// "text").
|
||||||
|
func displayType(lang *string, attFilename string) string {
|
||||||
|
if ext := attachmentExtName(attFilename); ext != "" {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
if lang == nil || *lang == "" {
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
return *lang
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachmentExtName returns the lowercase extension (without dot) of a
|
||||||
|
// filename, or "" when there is none.
|
||||||
|
func attachmentExtName(filename string) string {
|
||||||
|
name := filename
|
||||||
|
if i := strings.LastIndexByte(name, '/'); i >= 0 {
|
||||||
|
name = name[i+1:]
|
||||||
|
}
|
||||||
|
i := strings.LastIndexByte(name, '.')
|
||||||
|
if i <= 0 || i == len(name)-1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(name[i+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMine serves /api/mine: pastes created from this browser (#37).
|
||||||
|
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vid := currentViewerID(r)
|
||||||
|
if vid == "" {
|
||||||
|
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := parseLimit(r, 50, 100)
|
||||||
|
offset := parseOffset(r)
|
||||||
|
rows, total, err := a.store.ListMine(vid, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||||
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||||
|
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
||||||
|
"is_can": row.IsCan,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit := parseLimit(r, 25, 100)
|
||||||
|
offset := parseOffset(r)
|
||||||
|
rows, total, err := a.store.ListPublic(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 500, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||||
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||||
|
"custom_slug": store.NullStrPtr(row.CustomSlug),
|
||||||
|
"is_can": row.IsCan,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
row, err := a.store.GetPaste(id)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
http.Error(w, "not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
|
http.Error(w, "paste expired", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.PasswordHash.Valid {
|
||||||
|
http.Error(w, "password required", 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Burned() { // #49: read budget exhausted
|
||||||
|
http.Error(w, "not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #49 decision: raw reads count against the read budget too, with the
|
||||||
|
// same per-viewer dedupe window as page views. #58: a reader that loses
|
||||||
|
// the burn claim must not receive the content.
|
||||||
|
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||||
|
if !admitted {
|
||||||
|
http.Error(w, "not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #221: raw view of an image paste serves the image bytes themselves as
|
||||||
|
// an image, not the (empty) text content.
|
||||||
|
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) {
|
||||||
|
blobs := a.store.Blobs()
|
||||||
|
if blobs != nil {
|
||||||
|
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
|
||||||
|
defer blob.Close()
|
||||||
|
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
|
||||||
|
w.Header().Set("Content-Type", att.Mime)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
|
||||||
|
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #34: content_type is attacker-controlled via the create API. Serving it
|
||||||
|
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
||||||
|
// render as active content on this origin when fetched from /raw —
|
||||||
|
// stored XSS. Only pass through a fixed safe set; anything else is
|
||||||
|
// served as plain text with nosniff.
|
||||||
|
ct := row.ContentType
|
||||||
|
if !safeRawContentType(ct) {
|
||||||
|
ct = "text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
|
||||||
|
w.Write([]byte(row.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeRawContentType reports whether ct is in the fixed set of types that are
|
||||||
|
// safe to serve verbatim on /raw (no active-content execution contexts).
|
||||||
|
func safeRawContentType(ct string) bool {
|
||||||
|
base := ct
|
||||||
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||||
|
base = ct[:i]
|
||||||
|
}
|
||||||
|
base = strings.ToLower(strings.TrimSpace(base))
|
||||||
|
switch base {
|
||||||
|
case "text/plain", "text/markdown", "text/x-markdown",
|
||||||
|
"application/json", "application/pdf",
|
||||||
|
"image/png", "image/jpeg", "image/gif", "image/webp",
|
||||||
|
"application/octet-stream":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
can, err := a.store.GetCan(id)
|
||||||
|
if err != nil || can == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #4: password-protected cans go through the same unlock flow as pastes:
|
||||||
|
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
|
||||||
|
// inherit the protection (handleCanItem checks the same cookie).
|
||||||
|
if can.PasswordHash.Valid {
|
||||||
|
h := a.webHandlers()
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
if !rateLimitUnlock(can.ID, r) {
|
||||||
|
h.WriteRateLimited(w, 60)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ParseForm()
|
||||||
|
pw := r.FormValue("password")
|
||||||
|
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
|
||||||
|
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
a.renderCan(w, can)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.RenderPage(w, "unlock.html", map[string]any{
|
||||||
|
"Page": "unlock", "ID": can.ID, "Wrong": true,
|
||||||
|
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie("pw_" + can.ID)
|
||||||
|
if err != nil || c.Value != web.UnlockToken(can.ID) {
|
||||||
|
h.RenderPage(w, "unlock.html", map[string]any{
|
||||||
|
"Page": "unlock", "ID": can.ID, "Wrong": false,
|
||||||
|
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.renderCan(w, can)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCan renders the can view page: title/description and items as cards.
|
||||||
|
// Text items expand inline; files link to download.
|
||||||
|
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
|
||||||
|
h := a.webHandlers()
|
||||||
|
items, err := a.store.ListCanItems(can.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "db error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type canItem struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
ContentType string
|
||||||
|
Size string
|
||||||
|
IsFile bool
|
||||||
|
Content string
|
||||||
|
ContentHTML template.HTML
|
||||||
|
Language string
|
||||||
|
}
|
||||||
|
cards := make([]canItem, 0, len(items))
|
||||||
|
totalSize := 0
|
||||||
|
for _, it := range items {
|
||||||
|
totalSize += len(it.Content)
|
||||||
|
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
|
||||||
|
ci := canItem{
|
||||||
|
ID: it.ID,
|
||||||
|
Title: nullStrOr(it.Title, it.ID),
|
||||||
|
ContentType: it.ContentType,
|
||||||
|
Size: web.HumanSize(len(it.Content)),
|
||||||
|
IsFile: isFile,
|
||||||
|
Language: it.Language.String,
|
||||||
|
}
|
||||||
|
if !isFile {
|
||||||
|
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
|
||||||
|
}
|
||||||
|
cards = append(cards, ci)
|
||||||
|
}
|
||||||
|
h.RenderPage(w, "can.html", map[string]any{
|
||||||
|
"Page": "can",
|
||||||
|
"ID": can.ID,
|
||||||
|
"Title": nullStrOr(can.Title, "Untitled can"),
|
||||||
|
"Description": can.Description.String,
|
||||||
|
"HasDescription": can.Description.Valid && can.Description.String != "",
|
||||||
|
"HasPassword": can.PasswordHash.Valid,
|
||||||
|
"Items": cards,
|
||||||
|
"ItemCount": len(cards),
|
||||||
|
"SizeHuman": web.HumanSize(totalSize),
|
||||||
|
"CreatedAgo": web.AgoString(can.CreatedAt),
|
||||||
|
"CreatedAtUnix": can.CreatedAt,
|
||||||
|
"ExpiresAt": can.ExpiresAt.Valid,
|
||||||
|
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
|
||||||
|
func expiryStringIfValid(ns sql.NullInt64) string {
|
||||||
|
if !ns.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
remaining := ns.Int64 - time.Now().Unix()
|
||||||
|
s := remaining
|
||||||
|
switch {
|
||||||
|
case s < 3600:
|
||||||
|
return fmt.Sprintf("%dm", s/60)
|
||||||
|
case s < 86400:
|
||||||
|
return fmt.Sprintf("%dh", s/3600)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd", s/86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateEsc(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := a.webHandlers()
|
||||||
|
// unlock POST rate limiting is wired through h.RateLimitOK
|
||||||
|
h.HandlePasteView(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *apiServer) webHandlers() *web.Handlers {
|
||||||
|
return &web.Handlers{
|
||||||
|
UI: a.ui,
|
||||||
|
Store: a.store,
|
||||||
|
ViewerID: currentViewerID,
|
||||||
|
BurnWindowMin: a.burnViewerWindow,
|
||||||
|
RateLimitOK: func(id string, r *http.Request) bool { return rateLimitUnlock(id, r) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func envIntOr(k string, d int) int {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullStrOr(ns sql.NullString, def string) string {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.String
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvOr returns the env var value or default.
|
||||||
|
func EnvOr(k, d string) string { return envOr(k, d) }
|
||||||
|
|
||||||
|
// EnvIntOr returns the env int value or default.
|
||||||
|
func EnvIntOr(k string, d int) int { return envIntOr(k, d) }
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"palette/internal/store"
|
||||||
|
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
|
||||||
|
// created_at/expires_at, bypassing the API's timestamp handling.
|
||||||
|
func insertPasteWithSlug(t *testing.T, s *store.Store, slug string, createdAt, expiresAt int64) string {
|
||||||
|
t.Helper()
|
||||||
|
id := store.GenSlug(6)
|
||||||
|
if _, err := s.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func TestReleaseSlugOnExpiredPaste(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
|
||||||
|
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
|
||||||
|
t.Fatalf("released %d err %v, want 1", n, err)
|
||||||
|
}
|
||||||
|
if taken, _ := s.store.SlugTaken("release-notes"); taken {
|
||||||
|
t.Fatal("slug should be released after expiry")
|
||||||
|
}
|
||||||
|
// slug must be reusable by a new paste
|
||||||
|
p, err := s.store.CreatePaste(&store.Paste{Content: "new", CustomSlug: strPtr("release-notes")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reuse slug: %v", err)
|
||||||
|
}
|
||||||
|
if p.CustomSlug == nil || *p.CustomSlug != "release-notes" {
|
||||||
|
t.Fatal("new paste did not claim released slug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseSlugOnOldPaste(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
// created 31 days ago, no expiry -> released by 30-day reservation rule
|
||||||
|
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
|
||||||
|
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 1 {
|
||||||
|
t.Fatalf("released %d err %v, want 1", n, err)
|
||||||
|
}
|
||||||
|
if taken, _ := s.store.SlugTaken("old-url"); taken {
|
||||||
|
t.Fatal("slug should be released after 30-day reservation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
|
||||||
|
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
|
||||||
|
if n, err := s.store.ReleaseCustomSlugs(store.SlugReservationDays); err != nil || n != 0 {
|
||||||
|
t.Fatalf("released %d err %v, want 0", n, err)
|
||||||
|
}
|
||||||
|
for _, slug := range []string{"fresh-url", "fresh-url2"} {
|
||||||
|
if taken, _ := s.store.SlugTaken(slug); !taken {
|
||||||
|
t.Fatalf("slug %q should still be held", slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSweeperTickerReleasesSlugs(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
|
||||||
|
s.store.StartSweeper(10*time.Millisecond, store.SlugReservationDays)
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("ticker did not release slug in time")
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #33 sweep: the create API must enforce the same expiry window as the UI
|
||||||
|
// (1 minute .. 1 year). Previously -1h, 0s, 1ns and 30000h were all accepted,
|
||||||
|
// producing pastes that were born expired or effectively permanent.
|
||||||
|
func TestCreatePasteExpiryBounds(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
expiresIn string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{"-1h", 400},
|
||||||
|
{"-0s", 400},
|
||||||
|
{"0s", 400},
|
||||||
|
{"1ns", 400},
|
||||||
|
{"59s", 400},
|
||||||
|
{"1m", 201},
|
||||||
|
{"90s", 201},
|
||||||
|
{"8760h", 201}, // exactly 1 year
|
||||||
|
{"8785h", 400}, // 1 year + 1 day: over the max
|
||||||
|
{"30000h", 400}, // ~3.4 years, over the max
|
||||||
|
}
|
||||||
|
globalLimiter = newLimiter() // one fresh bucket for the whole table
|
||||||
|
for _, c := range cases {
|
||||||
|
globalLimiter = newLimiter() // avoid create rate limit between cases
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes",
|
||||||
|
strings.NewReader(`{"content":"x","expires_in":"`+c.expiresIn+`"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != c.wantCode {
|
||||||
|
t.Errorf("expires_in %q: got %d want %d (%s)",
|
||||||
|
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #33 sweep: HTML paste views must increment view_count. The increment was
|
||||||
|
// missing from handlePasteView, so the counter only moved on /raw.
|
||||||
|
func TestPasteViewIncrementsViewCount(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vc"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
id := jsonField(t, rec.Body.String(), "id")
|
||||||
|
|
||||||
|
// first render counts (no ?created=1 here: that's the just-created banner case)
|
||||||
|
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("view: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("api get: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `"view_count":2`) {
|
||||||
|
t.Fatalf("expected view_count 2 after two HTML views, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #33 sweep: the just-created banner render (?created=1) must NOT count as a
|
||||||
|
// view for the creator.
|
||||||
|
func TestJustCreatedViewDoesNotCount(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"jc"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
id := jsonField(t, rec.Body.String(), "id")
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/"+id+"?created=1&token=t", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if strings.Contains(rec.Body.String(), `"view_count":1`) {
|
||||||
|
t.Fatalf("just-created render counted as a view: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #68 input-validation helpers. Paste/can payloads are size-capped and list
|
||||||
|
// endpoints get a single place where limit/offset are parsed and clamped.
|
||||||
|
|
||||||
|
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
|
||||||
|
// admin-tunable content cap plus headroom for JSON field overhead, floored
|
||||||
|
// at 64KiB so a tiny admin-configured cap can't break small requests.
|
||||||
|
func (a *apiServer) maxRequestBody() int64 {
|
||||||
|
s := a.settings.get()
|
||||||
|
max := s.MaxContentBytes + 4096
|
||||||
|
// #38: multipart create carries up to one 25 MB file plus overhead
|
||||||
|
if a.cfg.MaxItemBytes+1<<20 > max {
|
||||||
|
max = a.cfg.MaxItemBytes + 1<<20
|
||||||
|
}
|
||||||
|
if max < 64*1024 {
|
||||||
|
max = 64 * 1024
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitRequestBody wraps the request body with http.MaxBytesReader so
|
||||||
|
// oversized payloads are cut off server-side instead of being fully decoded
|
||||||
|
// into memory before the per-field size check runs (#68). A read over the
|
||||||
|
// cap surfaces as *http.MaxBytesError, which handlers map to 413.
|
||||||
|
func (a *apiServer) limitRequestBody(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Body != nil {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, a.maxRequestBody())
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBodyTooLarge emits the 413 response for a body rejected by
|
||||||
|
// MaxBytesReader.
|
||||||
|
func writeBodyTooLarge(w http.ResponseWriter) {
|
||||||
|
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBodyTooLarge reports whether err came from http.MaxBytesReader.
|
||||||
|
func isBodyTooLarge(err error) bool {
|
||||||
|
var mbe *http.MaxBytesError
|
||||||
|
return errors.As(err, &mbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkContent validates paste content: rejects whitespace-only content
|
||||||
|
// (400) and content over the byte cap (413). Returns (0, "") when valid.
|
||||||
|
func checkContent(content string, maxBytes int64) (int, string) {
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return http.StatusBadRequest, "content is required"
|
||||||
|
}
|
||||||
|
if int64(len(content)) > maxBytes { // #40/#68: admin-tunable cap
|
||||||
|
return http.StatusRequestEntityTooLarge,
|
||||||
|
fmt.Sprintf("content exceeds max %d bytes", maxBytes)
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLimit clamps the ?limit query param: missing/non-numeric/zero/negative
|
||||||
|
// or over-max values fall back to def. Zero intentionally maps to the default
|
||||||
|
// page size, matching the pre-existing `<= 0` clamp (#68).
|
||||||
|
func parseLimit(r *http.Request, def, max int) int {
|
||||||
|
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if err != nil || n <= 0 || n > max {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOffset clamps the ?offset query param: missing/non-numeric or negative
|
||||||
|
// values become 0 (#68: negative offsets previously passed through to SQL).
|
||||||
|
func parseOffset(r *http.Request) int {
|
||||||
|
n, err := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBurnAfterReads validates the burn_after_reads field (#68): negative
|
||||||
|
// values are rejected; zero/absent mean the default single read.
|
||||||
|
func parseBurnAfterReads(n int) error {
|
||||||
|
if n < 0 {
|
||||||
|
return errors.New("burn_after_reads must be a positive number")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// #86: bounds for free-form metadata fields on create.
|
||||||
|
const (
|
||||||
|
maxTitleLen = 200
|
||||||
|
maxLanguageLen = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
// languageRe restricts language to identifiers like go, c#, f#, c++, objc.
|
||||||
|
var languageRe = regexp.MustCompile(`^[a-zA-Z0-9+#-]{1,40}$`)
|
||||||
|
|
||||||
|
// checkTitle validates the paste title (#86): over-max titles are truncated
|
||||||
|
// to 200 characters so a bloated listing entry can't be stored; whitespace
|
||||||
|
// is trimmed first.
|
||||||
|
func checkTitle(title string) (string, error) {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if len(title) > maxTitleLen {
|
||||||
|
return truncateRunes(title, maxTitleLen), nil
|
||||||
|
}
|
||||||
|
return title, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkLanguage validates the language field (#86): optional, max 40 chars,
|
||||||
|
// and must match ^[a-zA-Z0-9+#-]{1,40}$. Returns "" for absent/blank values.
|
||||||
|
// Anything else malformed is a 400.
|
||||||
|
func checkLanguage(lang string) (string, error) {
|
||||||
|
lang = strings.TrimSpace(lang)
|
||||||
|
if lang == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(lang) > maxLanguageLen || !languageRe.MatchString(lang) {
|
||||||
|
return "", fmt.Errorf("language must match ^[a-zA-Z0-9+#-]{1,40}$ (max %d chars)", maxLanguageLen)
|
||||||
|
}
|
||||||
|
return lang, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunes cuts s to at most max runes, keeping the prefix intact.
|
||||||
|
func truncateRunes(s string, max int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max])
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"palette/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #95: HTML paste views dedupe per viewer (vwr cookie) within the burn viewer
|
||||||
|
// window, reusing the paste_views table. Repeated views by the same viewer do
|
||||||
|
// not inflate view_count; a different viewer counts.
|
||||||
|
func TestHTMLViewsDedupedPerViewer(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vd"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
id := jsonField(t, rec.Body.String(), "id")
|
||||||
|
|
||||||
|
view := func(vwr string) {
|
||||||
|
req := httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
if vwr != "" {
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: vwr})
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("view: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count := func() string {
|
||||||
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// same viewer (same cookie) renders twice: still 1 view
|
||||||
|
view("v-a")
|
||||||
|
view("v-a")
|
||||||
|
if got := count(); !strings.Contains(got, `"view_count":1`) {
|
||||||
|
t.Fatalf("expected view_count 1 after duplicate viewer views, got: %s", got)
|
||||||
|
}
|
||||||
|
// a different viewer counts
|
||||||
|
view("v-b")
|
||||||
|
if got := count(); !strings.Contains(got, `"view_count":2`) {
|
||||||
|
t.Fatalf("expected view_count 2 after second viewer, got: %s", got)
|
||||||
|
}
|
||||||
|
// viewer without cookie counts every time (plain API clients, #49 decision)
|
||||||
|
view("")
|
||||||
|
view("")
|
||||||
|
if got := count(); !strings.Contains(got, `"view_count":4`) {
|
||||||
|
t.Fatalf("expected view_count 4 after uncoookied views, got: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #95: raw views always count, even from a cookie-holding viewer (#49 decision).
|
||||||
|
func TestRawViewsAlwaysCount(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"rv"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
id := jsonField(t, rec.Body.String(), "id")
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
req := httptest.NewRequest("GET", "/raw/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "same-viewer"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("raw view: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if got := rec.Body.String(); !strings.Contains(got, `"view_count":3`) {
|
||||||
|
t.Fatalf("expected view_count 3 after 3 raw views by one viewer, got: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #95: after the dedupe window passes, the same viewer counts again.
|
||||||
|
func TestViewDedupeWindowExpires(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"we"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
id := jsonField(t, rec.Body.String(), "id")
|
||||||
|
|
||||||
|
view := func() {
|
||||||
|
req := httptest.NewRequest("GET", "/"+id, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-w"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("view: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view()
|
||||||
|
view()
|
||||||
|
// jump past the 15-minute window
|
||||||
|
realNow := store.TimeNow
|
||||||
|
store.TimeNow = func() time.Time { return realNow().Add(16 * time.Minute) }
|
||||||
|
defer func() { store.TimeNow = realNow }()
|
||||||
|
view()
|
||||||
|
req = httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if got := rec.Body.String(); !strings.Contains(got, `"view_count":2`) {
|
||||||
|
t.Fatalf("expected view_count 2 after window expiry, got: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-enry/go-enry/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hintRule is a lightweight regex hint that nudges detection. Hints don't
|
||||||
|
// decide on their own: matching hints are passed to enry's classifier as
|
||||||
|
// candidate languages, and enry (trained on Linguist samples) makes the final
|
||||||
|
// call. Adding a language later is usually a one-line addition here plus the
|
||||||
|
// dropdown in web/templates/new.html.
|
||||||
|
type hintRule struct {
|
||||||
|
lang string // enry language name
|
||||||
|
re *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintRules are evaluated in order; keep more specific languages earlier so
|
||||||
|
// ties break in their favor.
|
||||||
|
var hintRules = []hintRule{
|
||||||
|
{"Dockerfile", regexp.MustCompile(`(?mi)^(FROM\s+\S+:\S*|RUN\s+\S+|COPY\s+\S+\s+\S+|ENTRYPOINT\s+|WORKDIR\s+/)`)},
|
||||||
|
{"Diff", regexp.MustCompile(`(?m)^(diff --git|--- a/|\+\+\+ b/|@@ -\d+)`)},
|
||||||
|
{"PHP", regexp.MustCompile(`(?m)<\?php|\$\w+\s*=\s*[^=]|\becho\s+["'$]`)},
|
||||||
|
{"HTML", regexp.MustCompile(`(?i)<(!DOCTYPE|html|head|body|div|span|script|p|a)\b`)},
|
||||||
|
{"XML", regexp.MustCompile(`(?m)^<\?xml\b|<\/?[a-zA-Z][\w.-]*:[\w.-]*[>\s]`)},
|
||||||
|
{"CSS", regexp.MustCompile(`(?m)(^|\})\s*[^{}@]+\{[^}]*:[^}]*\}|@(media|import|font-face)\b`)},
|
||||||
|
{"TypeScript", regexp.MustCompile(`(?m)(:\s*(string|number|boolean|any)\b|\binterface \w+ \{|\btype \w+ =|\bimplements \w+)`)},
|
||||||
|
{"TOML", regexp.MustCompile(`(?m)^\[[\w."-]+\]\s*$|^\w[\w-]*\s*=\s*("[^"]*"|\d+|true|false|\[[^\]]*\])\s*$`)},
|
||||||
|
{"INI", regexp.MustCompile(`(?m)^\[[\w.-]+\]\s*$|^\w[\w.-]*\s*=\s*\S+\s*$`)},
|
||||||
|
{"Ruby", regexp.MustCompile(`(?m)(\bdef \w+($|\s)|\brequire ['"]|\bputs \w+|@\w+\s*=\s*[^=]|\bend\b\s*$)`)},
|
||||||
|
{"Perl", regexp.MustCompile(`(?m)(\buse strict\b|\bmy \$\w+|->\{|sub \w+ \{)`)},
|
||||||
|
{"Lua", regexp.MustCompile(`(?m)(\bfunction\s+\w+\s*\(|\blocal \w+\s*=|\bthen\b|\belseif\b|\.\.\.)`)},
|
||||||
|
{"Go", regexp.MustCompile(`(?m)^\s*(package \w+|import \(|func (\w+|\() )`)},
|
||||||
|
{"Python", regexp.MustCompile(`(?m)^\s*(def \w+|class \w+|import \w+|from \w+ import |@\w+)`)},
|
||||||
|
{"JavaScript", regexp.MustCompile(`(?m)(\bconst \w+ = |require\(|import \w+ from |=> \{|\bconsole\.log\()` )},
|
||||||
|
{"Rust", regexp.MustCompile(`(?m)(\bfn \w+|let mut \b|\bimpl \b|use std::)`)},
|
||||||
|
{"Java", regexp.MustCompile(`(?m)(\bpublic (static |final |class )|\bSystem\.out\.print|import java\.)`)},
|
||||||
|
{"C", regexp.MustCompile(`(?m)(#include\s*<\w+\.h>|printf\(|\bint main\()` )},
|
||||||
|
{"C++", regexp.MustCompile(`(?m)(#include\s*<(iostream|vector|string)>|std::|\bcout\s*<<)`)},
|
||||||
|
{"SQL", regexp.MustCompile(`(?i)\b(SELECT .+ FROM|INSERT INTO|CREATE TABLE|UPDATE \w+ SET)\b`)},
|
||||||
|
{"YAML", regexp.MustCompile(`(?m)^(\w[\w-]*:\s*(\||\S)| \w[\w-]*: |---\s*$)`)},
|
||||||
|
{"Markdown", regexp.MustCompile("(?m)^(#{1,6} \\S|\\|.*\\||-\\s\\[\\s?\\]|```)")},
|
||||||
|
{"Shell", regexp.MustCompile(`(?m)^(#!.*bash|#!.*sh|\w+\(\)\s*\{)` )},
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonical maps enry display names to the lowercase ids we store and render.
|
||||||
|
var canonical = map[string]string{
|
||||||
|
"Dockerfile": "dockerfile",
|
||||||
|
"C#": "csharp",
|
||||||
|
"Shell": "bash",
|
||||||
|
}
|
||||||
|
|
||||||
|
// guessLang detects a language from pasted content. Order: fast decisive
|
||||||
|
// paths (empty, JSON, unambiguous markers enry can't see without a filename),
|
||||||
|
// then enry strategies (shebangs, XML decl, modelines, content heuristics),
|
||||||
|
// then enry's classifier seeded by our regex hints.
|
||||||
|
// GuessLang detects a language from pasted content.
|
||||||
|
func GuessLang(s string) string {
|
||||||
|
src := strings.TrimSpace(s)
|
||||||
|
if src == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON: must start with { or [ and parse — cheaper and more decisive
|
||||||
|
// than the classifier for pasted JSON, and handles compact single-line
|
||||||
|
// JSON that content heuristics miss.
|
||||||
|
if src[0] == '{' || src[0] == '[' {
|
||||||
|
var v any
|
||||||
|
if json.Unmarshal([]byte(src), &v) == nil {
|
||||||
|
return "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enry's built-in strategies: shebangs, XML declaration, modelines,
|
||||||
|
// content heuristics.
|
||||||
|
if lang := enry.GetLanguage("", []byte(src)); lang != "" && lang != enry.OtherLanguage {
|
||||||
|
return normalizeLang(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect hint-matched languages as classifier candidates
|
||||||
|
cands := []string{}
|
||||||
|
for _, h := range hintRules {
|
||||||
|
if h.re.MatchString(src) {
|
||||||
|
cands = append(cands, h.lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cands) > 0 {
|
||||||
|
// enry's Bayesian classifier (trained on Linguist samples) picks the
|
||||||
|
// best of the hint candidates; fall back to the first hint if it
|
||||||
|
// can't decide.
|
||||||
|
if lang, _ := enry.GetLanguageByClassifier([]byte(src), cands); lang != "" {
|
||||||
|
return normalizeLang(lang)
|
||||||
|
}
|
||||||
|
return normalizeLang(cands[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeLang maps enry display names to our lowercase stored ids.
|
||||||
|
func normalizeLang(lang string) string {
|
||||||
|
if c, ok := canonical[lang]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return strings.ToLower(lang)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGuessLangExisting covers languages detected before the enry switch and
|
||||||
|
// still expected to work after it.
|
||||||
|
func TestGuessLangExisting(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"package main\n\nfunc main() {}\n": "go",
|
||||||
|
"def foo():\n return 1\n": "python",
|
||||||
|
"const x = 1;\nconsole.log(x);\n": "javascript",
|
||||||
|
"{\"a\": 1, \"b\": [2, 3]}\n": "json",
|
||||||
|
"hello world just some text": "text",
|
||||||
|
"": "",
|
||||||
|
"fn main() {\n let x = 1;\n}\n": "rust",
|
||||||
|
"#include <stdio.h>\nint main() { printf(\"hi\"); }\n": "c",
|
||||||
|
"SELECT id, name FROM users WHERE active = 1;\n": "sql",
|
||||||
|
"title: demo\nitems:\n - one\n - two\n": "yaml",
|
||||||
|
"# Demo\n\nsome *markdown* text with a [link](http://x)\n": "markdown",
|
||||||
|
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
|
||||||
|
}
|
||||||
|
for src, want := range cases {
|
||||||
|
if got := GuessLang(src); got != want {
|
||||||
|
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuessLangNewLanguages covers the languages added to the dropdown as part
|
||||||
|
// of the enry integration (#41).
|
||||||
|
func TestGuessLangNewLanguages(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"interface User {\n name: string;\n age: number;\n}\n": "typescript",
|
||||||
|
"<!DOCTYPE html>\n<html>\n<head><title>hi</title></head>\n</html>\n": "html",
|
||||||
|
".container {\n display: flex;\n padding: 4px;\n}\n": "css",
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root><item>x</item></root>\n": "xml",
|
||||||
|
"<?php\nfunction hi() { echo 'x'; }\n": "php",
|
||||||
|
"def greet(name)\n puts \"hi #{name}\"\nend\n": "ruby",
|
||||||
|
"use strict;\nmy $x = 5;\nprint \"x is $x\\n\";\n": "perl",
|
||||||
|
"local x = 10\nfunction add(a, b)\n return a + b\nend\n": "lua",
|
||||||
|
"FROM golang:1.22\nRUN go build -o app .\nCMD [\"./app\"]\n": "dockerfile",
|
||||||
|
"[package]\nname = \"demo\"\nversion = \"0.1.0\"\n": "toml",
|
||||||
|
"[server]\nhost = 127.0.0.1\nport = 8080\n": "ini",
|
||||||
|
"diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1 +1 @@\n": "diff",
|
||||||
|
}
|
||||||
|
for src, want := range cases {
|
||||||
|
if got := GuessLang(src); got != want {
|
||||||
|
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuessLangMagicMarkers verifies enry's built-in shebang / signature
|
||||||
|
// handling that replaced the hand-rolled magic-marker pre-checks (#41).
|
||||||
|
func TestGuessLangMagicMarkers(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"#!/usr/bin/env node\nconsole.log('hi');\n": "javascript",
|
||||||
|
"#!/usr/bin/env python3\nimport sys\nprint(sys.argv)\n": "python",
|
||||||
|
"#!/usr/bin/python\nprint('x')\n": "python",
|
||||||
|
"#!/bin/bash\nset -euo pipefail\necho hi\n": "bash",
|
||||||
|
"#!/bin/sh\necho hi\n": "bash",
|
||||||
|
"<?php\necho 'x';\n": "php",
|
||||||
|
"<!DOCTYPE html>\n<html><body></body></html>": "html",
|
||||||
|
"FROM alpine:3.19\nCOPY app /app\n": "dockerfile",
|
||||||
|
"FROM ubuntu:24.04\nRUN apt-get update\n": "dockerfile",
|
||||||
|
"diff --git a/x.txt b/x.txt\nindex 123..456 100644\n": "diff",
|
||||||
|
"--- a/config.yml\n+++ b/config.yml\n@@ -1,2 +1,3 @@\n": "diff",
|
||||||
|
}
|
||||||
|
for src, want := range cases {
|
||||||
|
if got := GuessLang(src); got != want {
|
||||||
|
t.Errorf("GuessLang(%q) = %q, want %q", src, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuessLangCanonical verifies enry display names are mapped/lowercased to
|
||||||
|
// our stored ids.
|
||||||
|
func TestGuessLangCanonical(t *testing.T) {
|
||||||
|
if got := GuessLang("FROM debian:12\nCMD [\"sh\"]\n"); got != "dockerfile" {
|
||||||
|
t.Errorf("Dockerfile canonical mapping failed: got %q", got)
|
||||||
|
}
|
||||||
|
if got := GuessLang("#!/bin/sh\necho hi\n"); got != "bash" {
|
||||||
|
t.Errorf("Shell canonical mapping failed: got %q", got)
|
||||||
|
}
|
||||||
|
// uncurated languages still come back lowercase
|
||||||
|
if got := GuessLang("<h1>{{.Name}}</h1>\n"); got != strings.ToLower(got) {
|
||||||
|
t.Errorf("expected lowercase output, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package lang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minimal regex-based syntax highlighter for the paste view (#1).
|
||||||
|
// Server-side, no external dependencies. Tokens: comments, strings,
|
||||||
|
// numbers, keywords. Output is HTML with span classes styled in app.css.
|
||||||
|
// Highlighting is applied per line so the gutter stays line-aligned.
|
||||||
|
|
||||||
|
type hlLang struct {
|
||||||
|
keywords map[string]bool
|
||||||
|
lineComps []string // line comment prefixes
|
||||||
|
blockCom [2]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlLangs = map[string]hlLang{
|
||||||
|
"go": {
|
||||||
|
keywords: set("break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false string int int64 int32 uint byte rune bool float64 float32 error make new len cap append panic recover"),
|
||||||
|
lineComps: []string{"//"},
|
||||||
|
blockCom: [2]string{"/*", "*/"},
|
||||||
|
},
|
||||||
|
"python": {
|
||||||
|
keywords: set("and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield self print len range str int float list dict set tuple open"),
|
||||||
|
lineComps: []string{"#"},
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
keywords: set("async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new null of return static super switch this throw true false try typeof undefined var void while with yield console log document window Math JSON Array Object String Number Boolean Promise"),
|
||||||
|
lineComps: []string{"//"},
|
||||||
|
blockCom: [2]string{"/*", "*/"},
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
keywords: set("true false null"),
|
||||||
|
},
|
||||||
|
"bash": {
|
||||||
|
keywords: set("if then else elif fi for while do done case esac function return exit local export echo cd ls grep awk sed cat curl sudo apt git make echo read shift set unset trap source alias printf test rm mv cp mkdir chmod chown"),
|
||||||
|
lineComps: []string{"#"},
|
||||||
|
},
|
||||||
|
"sql": {
|
||||||
|
keywords: set("SELECT FROM WHERE INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE DROP ALTER INDEX JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER HAVING LIMIT OFFSET AND OR NOT NULL IS IN AS DISTINCT UNION ALL PRIMARY KEY FOREIGN REFERENCES DEFAULT UNIQUE CHECK VIEW WITH RETURNING EXISTS CASE WHEN THEN ELSE END COUNT SUM AVG MIN MAX"),
|
||||||
|
lineComps: []string{"--"},
|
||||||
|
blockCom: [2]string{"/*", "*/"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// aliases from the language dropdown / guesser
|
||||||
|
var hlAliases = map[string]string{
|
||||||
|
"py": "python", "python3": "python",
|
||||||
|
"js": "javascript", "node": "javascript", "typescript": "javascript", "ts": "javascript",
|
||||||
|
"sh": "bash", "shell": "bash", "zsh": "bash",
|
||||||
|
"golang": "go",
|
||||||
|
"c": "go", "cpp": "go", "c++": "go", "java": "go", "rust": "go", "rs": "go",
|
||||||
|
// C-family shares the same token rules as Go for highlighting purposes
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(words string) map[string]bool {
|
||||||
|
m := make(map[string]bool)
|
||||||
|
for _, w := range strings.Fields(words) {
|
||||||
|
m[w] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLang(lang string) (string, hlLang, bool) {
|
||||||
|
l := strings.ToLower(strings.TrimSpace(lang))
|
||||||
|
if l == "" || l == "text" || l == "markdown" || l == "yaml" {
|
||||||
|
return "", hlLang{}, false
|
||||||
|
}
|
||||||
|
if l == "yml" {
|
||||||
|
return "", hlLang{}, false
|
||||||
|
}
|
||||||
|
if g, ok := hlAliases[l]; ok {
|
||||||
|
if h, ok2 := hlLangs[g]; ok2 {
|
||||||
|
return g, h, true
|
||||||
|
}
|
||||||
|
return "", hlLang{}, false
|
||||||
|
}
|
||||||
|
h, ok := hlLangs[l]
|
||||||
|
return l, h, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlTokenRe = regexp.MustCompile(`("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|` + "`" + `[^` + "`" + `]*` + "`" + `?|//[^\n]*|--[^\n]*|#[^\n]*|/\*.*?(?:\*/|$)|\b(?:[0-9]+\.?[0-9]*|0x[0-9a-fA-F]+)\b|[A-Za-z_][A-Za-z0-9_]*)`)
|
||||||
|
|
||||||
|
func highlightLine(line string, h hlLang, lang string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
rest := line
|
||||||
|
// strip a trailing block-comment opener handled below; regex covers it
|
||||||
|
for {
|
||||||
|
loc := hlTokenRe.FindStringIndex(rest)
|
||||||
|
if loc == nil {
|
||||||
|
b.WriteString(html.EscapeString(rest))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(html.EscapeString(rest[:loc[0]]))
|
||||||
|
tok := rest[loc[0]:loc[1]]
|
||||||
|
cls := ""
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(tok, "//") || strings.HasPrefix(tok, "#") ||
|
||||||
|
strings.HasPrefix(tok, "--") || strings.HasPrefix(tok, "/*"):
|
||||||
|
// '#/--' are comments only in langs that use them
|
||||||
|
if (strings.HasPrefix(tok, "#") && !containsStr(h.lineComps, "#")) ||
|
||||||
|
(strings.HasPrefix(tok, "--") && !containsStr(h.lineComps, "--")) {
|
||||||
|
cls = ""
|
||||||
|
} else {
|
||||||
|
cls = "tok-com"
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(tok, "\"") || strings.HasPrefix(tok, "'") || strings.HasPrefix(tok, "`"):
|
||||||
|
cls = "tok-str"
|
||||||
|
case tok[0] >= '0' && tok[0] <= '9':
|
||||||
|
cls = "tok-num"
|
||||||
|
case h.keywords[tok]:
|
||||||
|
cls = "tok-kw"
|
||||||
|
}
|
||||||
|
if cls != "" {
|
||||||
|
b.WriteString(`<span class="` + cls + `">` + html.EscapeString(tok) + `</span>`)
|
||||||
|
} else {
|
||||||
|
b.WriteString(html.EscapeString(tok))
|
||||||
|
}
|
||||||
|
rest = rest[loc[1]:]
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlightCode returns HTML with highlighting spans; safe because all
|
||||||
|
// non-token text is html-escaped.
|
||||||
|
func HighlightCode(content, langID string) string {
|
||||||
|
l, h, ok := resolveLang(langID)
|
||||||
|
_ = l
|
||||||
|
if !ok {
|
||||||
|
return html.EscapeString(content)
|
||||||
|
}
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
out := make([]string, len(lines))
|
||||||
|
for i, line := range lines {
|
||||||
|
out[i] = highlightLine(line, h, langID)
|
||||||
|
}
|
||||||
|
// Note: lines are joined with "\n" on purpose — paste-lines.js splits on
|
||||||
|
// the newline to build its per-line .codeline blocks, then joins those
|
||||||
|
// with "" and strips whitespace-only text nodes (#205), so no newline
|
||||||
|
// text node ever reaches the rendered DOM.
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #38: file attachments, one file per paste (iteration 1). A paste either
|
||||||
|
// has text content OR one attached file. Bytes live in the BlobStore; this
|
||||||
|
// table carries the metadata.
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PasteID string `json:"paste_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Mime string `json:"mime"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
|
||||||
|
SizeHuman string `json:"-"` // template-only: human-readable size
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaxFilenameLen = 255
|
||||||
|
|
||||||
|
// ErrFileTooLarge is returned when an attachment exceeds the per-file cap.
|
||||||
|
var ErrFileTooLarge = errors.New("file too large")
|
||||||
|
|
||||||
|
// SanitizeFilename cleans a user-supplied filename: strips directory
|
||||||
|
// components, control chars, and caps at 255 bytes. The extension is never
|
||||||
|
// trusted for mime decisions (mime is sniffed server-side).
|
||||||
|
func SanitizeFilename(name string) string {
|
||||||
|
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
if r < 32 || r == 127 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
name = b.String()
|
||||||
|
if len(name) > MaxFilenameLen {
|
||||||
|
runes := []rune(name)
|
||||||
|
for len(string(runes)) > MaxFilenameLen {
|
||||||
|
runes = runes[:len(runes)-1]
|
||||||
|
}
|
||||||
|
name = string(runes)
|
||||||
|
}
|
||||||
|
if name == "" || name == "." || name == ".." {
|
||||||
|
name = "file"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAttachment stores the file bytes and inserts the attachments row.
|
||||||
|
// The mime MUST already be sniffed server-side (http.DetectContentType by
|
||||||
|
// the caller); it is never taken from the client.
|
||||||
|
func (s *Store) CreateAttachment(a *Attachment, r interface{ Read([]byte) (int, error) }, blobs BlobStore) error {
|
||||||
|
pasteID := a.PasteID
|
||||||
|
sha, size, err := blobs.Put(pasteID+"/pending", r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.Size > 0 && size > a.Size {
|
||||||
|
// caller-provided pre-check limit; treat as too large
|
||||||
|
blobs.Delete(pasteID + "/" + sha)
|
||||||
|
return ErrFileTooLarge
|
||||||
|
}
|
||||||
|
a.SHA256 = sha
|
||||||
|
a.Size = size
|
||||||
|
now := time.Now().Unix()
|
||||||
|
id := genSlug(20)
|
||||||
|
_, err = s.db.Exec(`INSERT INTO attachments (id, paste_id, filename, mime, size, sha256, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`, id, pasteID, a.Filename, a.Mime, size, sha, now)
|
||||||
|
if err != nil {
|
||||||
|
blobs.Delete(pasteID + "/" + sha)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.ID = id
|
||||||
|
a.CreatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAttachment returns the attachment row for a paste, or nil.
|
||||||
|
// One file per paste (iteration 1), so a single-row lookup keyed on paste.
|
||||||
|
func (s *Store) GetAttachmentForPaste(pasteID string) (*Attachment, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at
|
||||||
|
FROM attachments WHERE paste_id = ? ORDER BY created_at ASC LIMIT 1`, pasteID)
|
||||||
|
return scanAttachment(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetAttachment(id string) (*Attachment, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT id, paste_id, filename, mime, size, sha256, created_at
|
||||||
|
FROM attachments WHERE id = ?`, id)
|
||||||
|
return scanAttachment(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAttachment(row *sql.Row) (*Attachment, error) {
|
||||||
|
var a Attachment
|
||||||
|
err := row.Scan(&a.ID, &a.PasteID, &a.Filename, &a.Mime, &a.Size, &a.SHA256, &a.CreatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.SizeHuman = humanBytes(a.Size)
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanBytes formats a byte count for display (KB/MB).
|
||||||
|
func humanBytes(n int64) string {
|
||||||
|
switch {
|
||||||
|
case n >= 1<<20:
|
||||||
|
return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
|
||||||
|
case n >= 1<<10:
|
||||||
|
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAttachment removes the row and its blob.
|
||||||
|
func (s *Store) DeleteAttachment(a *Attachment, blobs BlobStore) error {
|
||||||
|
blobs.Delete(a.PasteID + "/" + a.SHA256)
|
||||||
|
_, err := s.db.Exec(`DELETE FROM attachments WHERE id = ?`, a.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAttachment reports whether a paste already carries a file (#38: one file per paste).
|
||||||
|
func (s *Store) HasAttachment(pasteID string) bool {
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow(`SELECT COUNT(*) FROM attachments WHERE paste_id = ?`, pasteID).Scan(&n)
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashBytes is a small helper used by handlers to name/verify blobs.
|
||||||
|
func HashBytes(b []byte) string {
|
||||||
|
h := sha256.Sum256(b)
|
||||||
|
return string(h[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #38: blob storage abstraction. Files live outside SQLite on the data
|
||||||
|
// volume behind this interface so a MinIO/S3 backend can replace the fs
|
||||||
|
// implementation later without touching handlers or the UI.
|
||||||
|
|
||||||
|
// ErrBlobNotFound is returned by Get/Stat when the key does not exist.
|
||||||
|
var ErrBlobNotFound = errors.New("blob not found")
|
||||||
|
|
||||||
|
// BlobStore persists attachment bytes by key.
|
||||||
|
type BlobStore interface {
|
||||||
|
Put(key string, r io.Reader) (sha string, size int64, err error)
|
||||||
|
Get(key string) (io.ReadSeekCloser, error)
|
||||||
|
Delete(key string) error
|
||||||
|
Stat(key string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FsBlobStore is the filesystem implementation: blobs are stored under
|
||||||
|
// root/<key>, where key is "<paste-id>/<sha256-hex>" (see SanitizeBlobKey).
|
||||||
|
type FsBlobStore struct {
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFsBlobStore creates the blob root directory.
|
||||||
|
func NewFsBlobStore(root string) (*FsBlobStore, error) {
|
||||||
|
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &FsBlobStore{root: root}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeBlobKey validates a blob key and returns the safe on-disk path
|
||||||
|
// under root. Keys must be exactly "<paste-id>/<sha256-hex>"; anything with
|
||||||
|
// separators outside that shape, "..", or absolute paths is rejected, so a
|
||||||
|
// crafted key can never escape the blob root (traversal).
|
||||||
|
func (f *FsBlobStore) path(key string) (string, error) {
|
||||||
|
clean := filepath.ToSlash(key)
|
||||||
|
parts := strings.Split(clean, "/")
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" ||
|
||||||
|
parts[0] == "." || parts[0] == ".." || parts[1] == "." || parts[1] == ".." {
|
||||||
|
return "", fmt.Errorf("invalid blob key %q", key)
|
||||||
|
}
|
||||||
|
return filepath.Join(f.root, filepath.FromSlash(clean)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FsBlobStore) Put(key string, r io.Reader) (string, int64, error) {
|
||||||
|
dst, err := f.path(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
tmp := dst + ".tmp"
|
||||||
|
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
size, err := io.Copy(io.MultiWriter(out, h), r)
|
||||||
|
if cerr := out.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
sha := hex.EncodeToString(h.Sum(nil))
|
||||||
|
// The caller-supplied key must match the content hash; rename to the
|
||||||
|
// canonical "<paste-id>/<sha256>" path so keys are always content-derived.
|
||||||
|
canonical, err := f.path(key[:strings.IndexByte(key, '/')] + "/" + sha)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(canonical), 0o700); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, canonical); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
return sha, size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FsBlobStore) Get(key string) (io.ReadSeekCloser, error) {
|
||||||
|
dst, err := f.path(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := os.Open(dst)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, ErrBlobNotFound
|
||||||
|
}
|
||||||
|
return file, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FsBlobStore) Delete(key string) error {
|
||||||
|
dst, err := f.path(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = os.Remove(dst)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return ErrBlobNotFound
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
// best-effort cleanup of the now-empty paste directory
|
||||||
|
os.Remove(filepath.Dir(dst))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FsBlobStore) Stat(key string) (int64, error) {
|
||||||
|
dst, err := f.path(key)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(dst)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, ErrBlobNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return fi.Size(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBlobs(t *testing.T) *FsBlobStore {
|
||||||
|
t.Helper()
|
||||||
|
b, err := NewFsBlobStore(t.TempDir() + "/files")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobPutGetStatDeleteRoundtrip(t *testing.T) {
|
||||||
|
b := testBlobs(t)
|
||||||
|
data := []byte("hello attachment world")
|
||||||
|
sha, size, err := b.Put("abc123/pending", strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if size != int64(len(data)) {
|
||||||
|
t.Fatalf("size = %d want %d", size, len(data))
|
||||||
|
}
|
||||||
|
if len(sha) != 64 {
|
||||||
|
t.Fatalf("sha256 = %q", sha)
|
||||||
|
}
|
||||||
|
// canonical key is <paste-id>/<sha256>
|
||||||
|
got, err := b.Get("abc123/" + sha)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, len(data)+10)
|
||||||
|
n, _ := got.Read(buf)
|
||||||
|
got.Close()
|
||||||
|
if string(buf[:n]) != string(data) {
|
||||||
|
t.Fatalf("roundtrip mismatch: %q", buf[:n])
|
||||||
|
}
|
||||||
|
sz, err := b.Stat("abc123/" + sha)
|
||||||
|
if err != nil || sz != int64(len(data)) {
|
||||||
|
t.Fatalf("stat = %d, %v", sz, err)
|
||||||
|
}
|
||||||
|
if err := b.Delete("abc123/" + sha); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := b.Get("abc123/" + sha); err != ErrBlobNotFound {
|
||||||
|
t.Fatalf("get after delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobStatMissing(t *testing.T) {
|
||||||
|
b := testBlobs(t)
|
||||||
|
if _, err := b.Stat("nope/deadbeef"); err != ErrBlobNotFound {
|
||||||
|
t.Fatalf("want ErrBlobNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobTraversalPrevention(t *testing.T) {
|
||||||
|
b := testBlobs(t)
|
||||||
|
evil := []string{
|
||||||
|
"../../etc/passwd",
|
||||||
|
"../escape",
|
||||||
|
"..\\windows",
|
||||||
|
"/abs/path",
|
||||||
|
"a/b/c", // too many segments
|
||||||
|
"onlyone", // no slash
|
||||||
|
"./relative", // dot segment
|
||||||
|
"../..", // bare traversal
|
||||||
|
"ok/../traverse", // traversal inside
|
||||||
|
}
|
||||||
|
for _, key := range evil {
|
||||||
|
if _, _, err := b.Put(key, strings.NewReader("x")); err == nil {
|
||||||
|
t.Errorf("Put accepted evil key %q", key)
|
||||||
|
}
|
||||||
|
if _, err := b.Get(key); err == nil {
|
||||||
|
t.Errorf("Get accepted evil key %q", key)
|
||||||
|
}
|
||||||
|
if err := b.Delete(key); err == nil {
|
||||||
|
t.Errorf("Delete accepted evil key %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeFilename(t *testing.T) {
|
||||||
|
cases := [][2]string{
|
||||||
|
{"../../etc/passwd", "passwd"},
|
||||||
|
{"C:\\Users\\evil\\file.txt", "file.txt"},
|
||||||
|
{"normal.txt", "normal.txt"},
|
||||||
|
{"a<b>c", "a<b>c"},
|
||||||
|
{"", "file"},
|
||||||
|
{"..", "file"},
|
||||||
|
{".hidden", ".hidden"},
|
||||||
|
{"with\x00null.txt", "withnull.txt"},
|
||||||
|
{"new\nline.txt", "newline.txt"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := SanitizeFilename(c[0])
|
||||||
|
if got != c[1] {
|
||||||
|
t.Errorf("SanitizeFilename(%q) = %q want %q", c[0], got, c[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long := strings.Repeat("x", 300)
|
||||||
|
if got := SanitizeFilename(long); len(got) != MaxFilenameLen {
|
||||||
|
t.Errorf("long name len = %d want %d", len(got), MaxFilenameLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genDeletionToken returns a 32-char url-safe random token
|
||||||
|
func genDeletionToken() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
cryptoRead(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeNow is overridable in tests to inject the clock.
|
||||||
|
var TimeNow = time.Now
|
||||||
|
|
||||||
|
// RegisterRead applies the burn-after-read budget for one view (#49).
|
||||||
|
// For pastes with reads_limit set: the viewer's paste_views row is checked;
|
||||||
|
// a view within the burn viewer window of the viewer's last view is deduped
|
||||||
|
// (count=false). Otherwise reads_used is incremented, and the paste is
|
||||||
|
// soft-deleted (burned) once reads_used reaches reads_limit. Viewers without
|
||||||
|
// a cookie (plain API clients) count as their own viewer id "".
|
||||||
|
// For legacy plain burn_after_read pastes (no reads_limit), any read burns.
|
||||||
|
// #58: admission is atomic. Returns (remaining, admitted). admitted is true
|
||||||
|
// only when this caller may serve the content: for legacy burn pastes the
|
||||||
|
// caller wins exactly when its conditional soft delete flipped deleted_at
|
||||||
|
// (RowsAffected), and for read-budget pastes the caller wins exactly when its
|
||||||
|
// conditional UPDATE (reads_used < reads_limit) incremented the counter - so
|
||||||
|
// concurrent readers can never both consume the last read. Losing callers
|
||||||
|
// must treat the paste as gone. view_count is tracked separately and
|
||||||
|
// unaffected.
|
||||||
|
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, admitted bool) {
|
||||||
|
if !row.ReadsLimit.Valid {
|
||||||
|
if row.BurnAfterRead {
|
||||||
|
// #58: atomic claim - only the caller whose UPDATE actually
|
||||||
|
// flips deleted_at from NULL may serve the content.
|
||||||
|
ok, err := s.SoftDelete(row.ID)
|
||||||
|
r := 0
|
||||||
|
if err != nil || !ok {
|
||||||
|
return &r, false
|
||||||
|
}
|
||||||
|
return &r, true
|
||||||
|
}
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
now := TimeNow().Unix()
|
||||||
|
var last sql.NullInt64
|
||||||
|
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
|
||||||
|
row.ID, viewerID).Scan(&last)
|
||||||
|
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
|
||||||
|
r := int(row.ReadsLimit.Int64) - row.ReadsUsed
|
||||||
|
if r < 0 {
|
||||||
|
r = 0
|
||||||
|
}
|
||||||
|
return &r, true
|
||||||
|
}
|
||||||
|
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
|
||||||
|
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
|
||||||
|
row.ID, viewerID, now)
|
||||||
|
// #58: conditional increment - only succeeds while budget remains, so
|
||||||
|
// concurrent readers cannot both consume the final read.
|
||||||
|
res, err := s.db.Exec(`UPDATE pastes SET reads_used = reads_used + 1
|
||||||
|
WHERE id = ? AND deleted_at IS NULL AND reads_used < ?`, row.ID, row.ReadsLimit.Int64)
|
||||||
|
if err != nil {
|
||||||
|
r := 0
|
||||||
|
return &r, false
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
// Lost the race: budget exhausted (or paste already burned).
|
||||||
|
r := 0
|
||||||
|
return &r, false
|
||||||
|
}
|
||||||
|
var used int64
|
||||||
|
s.db.QueryRow(`SELECT reads_used FROM pastes WHERE id = ?`, row.ID).Scan(&used)
|
||||||
|
if used >= row.ReadsLimit.Int64 {
|
||||||
|
// Atomic burn; either way the paste is gone for future readers.
|
||||||
|
s.SoftDelete(row.ID)
|
||||||
|
}
|
||||||
|
r := int(row.ReadsLimit.Int64) - int(used)
|
||||||
|
if r < 0 {
|
||||||
|
r = 0
|
||||||
|
}
|
||||||
|
return &r, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burned reports whether a read-limited paste has exhausted its budget.
|
||||||
|
func (row *PasteRow) Burned() bool {
|
||||||
|
return row.ReadsLimit.Valid && int64(row.ReadsUsed) >= row.ReadsLimit.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletionTokenEqual(stored, given string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -16,16 +15,16 @@ var reservedSlugs = map[string]bool{
|
|||||||
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var errInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
|
var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit")
|
||||||
var errReservedSlug = errors.New("that slug is reserved")
|
var ErrReservedSlug = errors.New("that slug is reserved")
|
||||||
var errSlugTaken = errors.New("that slug is already taken")
|
var ErrSlugTaken = errors.New("that slug is already taken")
|
||||||
|
|
||||||
func ValidateCustomSlug(slug string) error {
|
func ValidateCustomSlug(slug string) error {
|
||||||
if !slugRE.MatchString(slug) {
|
if !slugRE.MatchString(slug) {
|
||||||
return errInvalidSlug
|
return ErrInvalidSlug
|
||||||
}
|
}
|
||||||
if reservedSlugs[strings.ToLower(slug)] {
|
if reservedSlugs[strings.ToLower(slug)] {
|
||||||
return errReservedSlug
|
return ErrReservedSlug
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -45,5 +44,3 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return n > 0, nil
|
return n > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = fmt.Sprintf // keep fmt if unused later
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -19,7 +19,8 @@ const (
|
|||||||
argonSaltLen = 16
|
argonSaltLen = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
func argon2idHash(pw string) (string, error) {
|
// Argon2IDHash hashes a password with argon2id.
|
||||||
|
func Argon2IDHash(pw string) (string, error) {
|
||||||
salt := make([]byte, argonSaltLen)
|
salt := make([]byte, argonSaltLen)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -27,11 +28,10 @@ func argon2idHash(pw string) (string, error) {
|
|||||||
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
argonMemory, argonTime, argonThreads,
|
argonMemory, argonTime, argonThreads,
|
||||||
base64.RawStdEncoding.EncodeToString(salt),
|
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||||
base64.RawStdEncoding.EncodeToString(key)), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPassword(hash, pw string) bool {
|
func CheckPassword(hash, pw string) bool {
|
||||||
parts := strings.Split(hash, "$")
|
parts := strings.Split(hash, "$")
|
||||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
return false
|
return false
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
// Package store provides the SQLite persistence layer for palette: schema
|
||||||
|
// migrations, the Store type and all queries, and the background sweeper.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlugReservationDays is the default custom-URL reservation window (admin-tunable via settings, #40).
|
||||||
|
const SlugReservationDays = 30
|
||||||
|
|
||||||
|
// SoftDeleteGraceDays is how long soft-deleted pastes linger before hard delete.
|
||||||
|
const SoftDeleteGraceDays = 7
|
||||||
|
|
||||||
|
type Paste struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CustomSlug *string `json:"custom_slug,omitempty"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Language *string `json:"language,omitempty"`
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Password *string `json:"password,omitempty"`
|
||||||
|
ExpiresIn *string `json:"expires_in,omitempty"`
|
||||||
|
BurnAfterRead bool `json:"burn_after_read,omitempty"`
|
||||||
|
BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1)
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
// #83: accept "public": true/false as an alias for visibility.
|
||||||
|
Public *bool `json:"public,omitempty"`
|
||||||
|
CanID *string `json:"can_id,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
|
ViewerID string `json:"-"` // set from vwr cookie server-side (#37)
|
||||||
|
readsLimit *int64 // #49: resolved read budget, not serialized
|
||||||
|
ViewCount int `json:"view_count"`
|
||||||
|
DeletionToken string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasteRow struct {
|
||||||
|
ID string
|
||||||
|
CustomSlug sql.NullString
|
||||||
|
Content string
|
||||||
|
ContentType string
|
||||||
|
Language sql.NullString
|
||||||
|
Title sql.NullString
|
||||||
|
PasswordHash sql.NullString
|
||||||
|
ExpiresAt sql.NullInt64
|
||||||
|
BurnAfterRead bool
|
||||||
|
ReadsLimit sql.NullInt64
|
||||||
|
ReadsUsed int
|
||||||
|
Visibility string
|
||||||
|
CanID sql.NullString
|
||||||
|
CreatedAt int64
|
||||||
|
DeletedAt sql.NullInt64
|
||||||
|
ViewCount int
|
||||||
|
Size int
|
||||||
|
DeletionToken sql.NullString
|
||||||
|
ViewerID sql.NullString
|
||||||
|
IsCan bool // set on list rows that are cans (#4)
|
||||||
|
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
|
||||||
|
}
|
||||||
|
|
||||||
|
type CanRow struct {
|
||||||
|
ID string
|
||||||
|
Title sql.NullString
|
||||||
|
Visibility string
|
||||||
|
PasswordHash sql.NullString
|
||||||
|
CreatedAt int64
|
||||||
|
DeletedAt sql.NullInt64
|
||||||
|
ExpiresAt sql.NullInt64
|
||||||
|
Description sql.NullString
|
||||||
|
ViewerID sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
blob BlobStore // #38: attachment byte storage (nil in some unit tests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenStore(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// #58: a single write connection. SQLite allows only one writer at a
|
||||||
|
// time; with multiple pooled connections concurrent writes surface as
|
||||||
|
// SQLITE_BUSY errors ("database is locked") instead of serializing, and
|
||||||
|
// the burn-after-read race tests saw spurious 500s under parallel reads.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
s := &Store{db: db}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// #38: attachment blobs live beside the database under <db>.files
|
||||||
|
blobs, err := NewFsBlobStore(path + ".files")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.blob = blobs
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pastes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
custom_slug TEXT UNIQUE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'text/plain',
|
||||||
|
language TEXT,
|
||||||
|
title TEXT,
|
||||||
|
password_hash TEXT,
|
||||||
|
expires_at INTEGER,
|
||||||
|
burn_after_read INTEGER DEFAULT 0,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
can_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
view_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deletion_token TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS paste_cans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
|
||||||
|
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
|
||||||
|
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49)
|
||||||
|
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
|
||||||
|
s.db.Exec(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4)
|
||||||
|
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
|
||||||
|
paste_id TEXT NOT NULL,
|
||||||
|
viewer_id TEXT NOT NULL,
|
||||||
|
last_viewed INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (paste_id, viewer_id)
|
||||||
|
)`) // #49: per-viewer read dedupe window
|
||||||
|
// #38: file attachments, one per paste in iteration 1. mime is sniffed
|
||||||
|
// server-side before insert; bytes live in the BlobStore keyed
|
||||||
|
// <paste_id>/<sha256>.
|
||||||
|
s.db.Exec(`CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
paste_id TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)`)
|
||||||
|
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_attachments_paste ON attachments(paste_id)`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blobs returns the attachment blob store (nil when unavailable, e.g. some
|
||||||
|
// unit-test stores).
|
||||||
|
func (s *Store) Blobs() BlobStore { return s.blob }
|
||||||
|
|
||||||
|
// SlugAlphabet is the paste-id charset (no ambiguous chars).
|
||||||
|
var SlugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
|
||||||
|
|
||||||
|
// genSlug generates a random slug of length n.
|
||||||
|
func genSlug(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = cryptoRead(b)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = SlugAlphabet[int(b[i])%len(SlugAlphabet)]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validExpiry reports whether an expires_in duration is in the accepted
|
||||||
|
// window. The UI restricts presets to 1 minute - 1 year (#48); the API must
|
||||||
|
// enforce the same bounds, otherwise negative/zero/absurd durations create
|
||||||
|
// pastes that are born expired (or effectively permanent).
|
||||||
|
const (
|
||||||
|
minExpiry = time.Minute
|
||||||
|
maxExpiry = 366 * 24 * time.Hour // 1 year (+ leap day headroom)
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidExpiry(d time.Duration) bool {
|
||||||
|
return d >= minExpiry && d <= maxExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
||||||
|
id := genSlug(6)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
var expiresAt *int64
|
||||||
|
if p.ExpiresIn != nil && *p.ExpiresIn != "" {
|
||||||
|
d, err := time.ParseDuration(*p.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid expires_in: %w", err)
|
||||||
|
}
|
||||||
|
if !ValidExpiry(d) {
|
||||||
|
return nil, fmt.Errorf("expires_in must be between 1 minute and 1 year")
|
||||||
|
}
|
||||||
|
t := now + int64(d.Seconds())
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
var pwHash *string
|
||||||
|
if p.Password != nil && *p.Password != "" {
|
||||||
|
h, err := Argon2IDHash(*p.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pwHash = &h
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
||||||
|
slug := *p.CustomSlug
|
||||||
|
if err := ValidateCustomSlug(slug); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
taken, err := s.SlugTaken(slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return nil, ErrSlugTaken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #49/#82: burn-after-read pastes carry a read budget (default 1 read).
|
||||||
|
// burn_after_reads > 0 alone implies burn mode even without burn_after_read.
|
||||||
|
if p.BurnAfterRead || (p.BurnAfterReads != nil && *p.BurnAfterReads > 0) {
|
||||||
|
limit := int64(1)
|
||||||
|
if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 {
|
||||||
|
limit = int64(*p.BurnAfterReads)
|
||||||
|
}
|
||||||
|
p.readsLimit = &limit
|
||||||
|
if !p.BurnAfterRead {
|
||||||
|
p.BurnAfterRead = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visibility := p.Visibility
|
||||||
|
// #83: "public": false -> unlisted, true -> public; overrides string field
|
||||||
|
if p.Public != nil {
|
||||||
|
if *p.Public {
|
||||||
|
visibility = "public"
|
||||||
|
} else {
|
||||||
|
visibility = "unlisted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if visibility == "" {
|
||||||
|
visibility = "public"
|
||||||
|
}
|
||||||
|
if visibility != "public" && visibility != "unlisted" {
|
||||||
|
return nil, errors.New("visibility must be public or unlisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := p.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
var slugVal *string
|
||||||
|
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
||||||
|
slugVal = p.CustomSlug
|
||||||
|
}
|
||||||
|
p.DeletionToken = genDeletionToken()
|
||||||
|
_, err := s.db.Exec(`INSERT INTO pastes
|
||||||
|
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id, reads_limit)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID, p.readsLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.ID = id
|
||||||
|
p.CreatedAt = now
|
||||||
|
p.ExpiresAt = expiresAt
|
||||||
|
p.Visibility = visibility
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
|
||||||
|
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
|
||||||
|
var r PasteRow
|
||||||
|
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublic backs /api/public and the public listing page. Visibility rules
|
||||||
|
// mirror the history page: only non-deleted, non-expired, non-can pastes are
|
||||||
|
// listed, and password-protected pastes are excluded at the query level
|
||||||
|
// (#65) so their metadata (title, slug, existence) never leaks.
|
||||||
|
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
|
||||||
|
FROM pastes p
|
||||||
|
LEFT JOIN attachments a ON a.paste_id = p.id
|
||||||
|
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, ''
|
||||||
|
FROM paste_cans
|
||||||
|
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []PasteRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r PasteRow
|
||||||
|
var cs, lang, title sql.NullString
|
||||||
|
var isCan int
|
||||||
|
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
r.CustomSlug = cs
|
||||||
|
r.Language = lang
|
||||||
|
r.Title = title
|
||||||
|
r.IsCan = isCan == 1
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?))
|
||||||
|
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
|
||||||
|
time.Now().Unix(), time.Now().Unix()).Scan(&total)
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
|
||||||
|
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
|
||||||
|
FROM pastes p
|
||||||
|
LEFT JOIN attachments a ON a.paste_id = p.id
|
||||||
|
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND p.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, ''
|
||||||
|
FROM paste_cans
|
||||||
|
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []PasteRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r PasteRow
|
||||||
|
var cs, lang, title sql.NullString
|
||||||
|
var isCan int
|
||||||
|
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
r.CustomSlug, r.Language, r.Title = cs, lang, title
|
||||||
|
r.IsCan = isCan == 1
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?))
|
||||||
|
+ (SELECT COUNT(*) FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
|
||||||
|
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MineOwner returns the stored viewer_id for a paste, or "" if none.
|
||||||
|
func (s *Store) MineOwner(id string) (string, error) {
|
||||||
|
var vid sql.NullString
|
||||||
|
err := s.db.QueryRow(`SELECT viewer_id FROM pastes WHERE id = ? AND deleted_at IS NULL`, id).Scan(&vid)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !vid.Valid {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return vid.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete marks a paste deleted (burned) atomically (#58): the deleted_at
|
||||||
|
// IS NULL guard means only the first caller flips the row. Returns true when
|
||||||
|
// this call performed the delete (RowsAffected > 0), false when the paste was
|
||||||
|
// already deleted - callers use this to decide read admission atomically.
|
||||||
|
func (s *Store) SoftDelete(id string) (bool, error) {
|
||||||
|
res, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementViews counts one view. With a viewerID (#95): views are deduped
|
||||||
|
// per-viewer within burnViewerWindow minutes using the paste_views table,
|
||||||
|
// namespaced with a "views/" viewer prefix so these rows never collide with
|
||||||
|
// RegisterRead's burn-after-read dedupe rows (which key on the raw viewer id).
|
||||||
|
// Raw views always count (#49 decision) — call with viewerID="" for those.
|
||||||
|
// Returns true when the view was counted.
|
||||||
|
func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool {
|
||||||
|
if viewerID == "" {
|
||||||
|
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
now := TimeNow().Unix()
|
||||||
|
vkey := "views/" + viewerID
|
||||||
|
var last sql.NullInt64
|
||||||
|
s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`,
|
||||||
|
id, vkey).Scan(&last)
|
||||||
|
if last.Valid && now-last.Int64 < int64(burnWindowMinutes)*60 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
|
||||||
|
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
|
||||||
|
id, vkey, now)
|
||||||
|
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace.
|
||||||
|
func (s *Store) SweepExpired() {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
|
||||||
|
// #4: cans expire too — mirror paste behavior
|
||||||
|
s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
|
||||||
|
grace := now - SoftDeleteGraceDays*86400
|
||||||
|
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseCustomSlugs frees custom URLs so they can be reused:
|
||||||
|
// - pastes whose expires_at has passed (expired or soft-deleted/expired),
|
||||||
|
// - pastes created more than reservationDays days ago (custom URLs are a
|
||||||
|
// reservation, not permanent).
|
||||||
|
//
|
||||||
|
// It returns the number of pastes whose custom_slug was released.
|
||||||
|
func (s *Store) ReleaseCustomSlugs(reservationDays int) (int64, error) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := s.db.Exec(`UPDATE pastes SET custom_slug = NULL
|
||||||
|
WHERE custom_slug IS NOT NULL
|
||||||
|
AND (expires_at IS NOT NULL AND expires_at > 0 AND expires_at < ?
|
||||||
|
OR created_at < ?)`,
|
||||||
|
now, now-int64(reservationDays)*86400)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("released %d custom slug(s)", n)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StartSweeper(every time.Duration, reservationDays int) {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
for range t.C {
|
||||||
|
s.SweepExpired()
|
||||||
|
s.ReleaseCustomSlugs(reservationDays)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func NullStrPtr(ns sql.NullString) *string {
|
||||||
|
if ns.Valid {
|
||||||
|
return &ns.String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete removes a paste row entirely (deletion-token redeem).
|
||||||
|
func (s *Store) HardDelete(id string) {
|
||||||
|
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCan inserts a paste_can row with optional custom slug (parity with
|
||||||
|
// pastes: validated by the same rules, checked against both tables).
|
||||||
|
// Returns ErrSlugTaken / ErrInvalidSlug / ErrReservedSlug on conflict.
|
||||||
|
func (s *Store) CreateCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64, customSlug *string) error {
|
||||||
|
if customSlug != nil && *customSlug != "" {
|
||||||
|
slug := *customSlug
|
||||||
|
if err := ValidateCustomSlug(slug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
taken, err := s.SlugTaken(slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return ErrSlugTaken
|
||||||
|
}
|
||||||
|
canID = slug
|
||||||
|
}
|
||||||
|
if visibility == "" {
|
||||||
|
visibility = "public"
|
||||||
|
}
|
||||||
|
if visibility != "public" && visibility != "unlisted" {
|
||||||
|
return errors.New("visibility must be public or unlisted")
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDeleteCan marks a can deleted (its items stay; they are unlisted and
|
||||||
|
// hidden from listings by can_id and disappear with the can's page).
|
||||||
|
func (s *Store) SoftDeleteCan(canID string) (bool, error) {
|
||||||
|
res, err := s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), canID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCan removes an (empty/aborted) can row.
|
||||||
|
func (s *Store) DeleteCan(canID string) {
|
||||||
|
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertCanItem adds an item paste belonging to a can.
|
||||||
|
func (s *Store) InsertCanItem(canID, title, content, contentType string, language *string, expiresAt, binary *string, now int64) error {
|
||||||
|
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
|
||||||
|
_, err := s.db.Exec(`INSERT INTO pastes
|
||||||
|
(id, content, content_type, language, title, visibility, can_id, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)`,
|
||||||
|
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
|
||||||
|
_ = expiresAt
|
||||||
|
_ = binary
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetCan(id string) (*CanRow, error) {
|
||||||
|
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at, description, viewer_id
|
||||||
|
FROM paste_cans WHERE (id = ?) AND deleted_at IS NULL`, id)
|
||||||
|
var c CanRow
|
||||||
|
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt, &c.Description, &c.ViewerID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count
|
||||||
|
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []PasteRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r PasteRow
|
||||||
|
if err := rows.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenSlug is the exported slug generator.
|
||||||
|
func GenSlug(n int) string { return genSlug(n) }
|
||||||
|
|
||||||
|
// Exec runs a raw statement (test helper).
|
||||||
|
func (s *Store) Exec(query string, args ...any) (int64, error) {
|
||||||
|
res, err := s.db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryInt runs a query returning a single integer (test helper).
|
||||||
|
func (s *Store) QueryInt(query string, args ...any) int {
|
||||||
|
var n int
|
||||||
|
s.db.QueryRow(query, args...).Scan(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #139 regression guards: with CSP script-src/style-src 'self' (no
|
||||||
|
// 'unsafe-inline'), the templates must not carry inline <script> blocks or
|
||||||
|
// style="" attributes, and the external scripts referenced must exist.
|
||||||
|
func TestNoInlineScripts(t *testing.T) {
|
||||||
|
entries, err := fs.ReadDir(tmplFS, "templates")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`(?s)<script[^>]*>.*?</script>`)
|
||||||
|
srcOnly := regexp.MustCompile(`<script[^>]+src=`)
|
||||||
|
for _, e := range entries {
|
||||||
|
b, err := fs.ReadFile(tmplFS, "templates/"+e.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range re.FindAll(b, -1) {
|
||||||
|
if srcOnly.Match(m) {
|
||||||
|
continue // external script tag with src: fine
|
||||||
|
}
|
||||||
|
t.Errorf("%s: inline <script> block found (CSP #139): %q", e.Name(), string(m[:60]))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), " onclick=") || strings.Contains(string(b), "onload=") {
|
||||||
|
t.Errorf("%s: inline event handler attribute found (CSP #139)", e.Name())
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "style=\"") {
|
||||||
|
t.Errorf("%s: inline style attribute found (CSP #139)", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferencedStaticScriptsExist(t *testing.T) {
|
||||||
|
entries, _ := fs.ReadDir(tmplFS, "templates")
|
||||||
|
var staticFiles embed.FS = staticFS
|
||||||
|
for _, e := range entries {
|
||||||
|
b, err := fs.ReadFile(tmplFS, "templates/"+e.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range regexp.MustCompile(`<script src="/static/([^"]+)"`).FindAllSubmatch(b, -1) {
|
||||||
|
name := string(m[1])
|
||||||
|
if _, err := fs.ReadFile(staticFiles, "static/"+name); err != nil {
|
||||||
|
t.Errorf("%s references /static/%s: %v", e.Name(), name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #59: SecurityHeaders must add the CSP and hardening headers to rendered
|
||||||
|
// HTML responses only; JSON and /raw responses pass through untouched.
|
||||||
|
func TestSecurityHeaders(t *testing.T) {
|
||||||
|
pages := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte("<html><body>ok</body></html>"))
|
||||||
|
})
|
||||||
|
h := SecurityHeaders(pages)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||||
|
wantCSP := "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'"
|
||||||
|
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
|
||||||
|
t.Errorf("CSP = %q, want %q", got, wantCSP)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
|
||||||
|
t.Errorf("Referrer-Policy = %q, want no-referrer", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||||
|
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON/raw responses: headers are now set unconditionally BEFORE the handler
|
||||||
|
// runs. The previous post-handler approach was silently dropped once a page
|
||||||
|
// handler flushed its template output (headers must be set before WriteHeader).
|
||||||
|
// CSP/nosniff/referrer on non-HTML bodies is harmless and desirable.
|
||||||
|
jsonh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
jsonh.ServeHTTP(rec, httptest.NewRequest("GET", "/api/x", nil))
|
||||||
|
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
|
||||||
|
t.Errorf("CSP missing on JSON response: got %q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
|
||||||
|
t.Errorf("Referrer-Policy missing on JSON response: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// #99/#139: admin lock + settings panel. Key lives in sessionStorage for this visit only.
|
||||||
|
(function () {
|
||||||
|
var KEY = 'palette_admin_key';
|
||||||
|
var keyInput = document.getElementById('admin-key');
|
||||||
|
var status = document.getElementById('admin-key-status');
|
||||||
|
var panel = document.getElementById('admin-panel');
|
||||||
|
|
||||||
|
function key() { return sessionStorage.getItem(KEY) || ''; }
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
// allow callers to override the key header (e.g. validating a typed key, #99)
|
||||||
|
opts.headers = Object.assign({ 'X-Admin-Key': key() }, opts.headers || {});
|
||||||
|
if (opts.body) opts.headers['Content-Type'] = 'application/json';
|
||||||
|
return fetch(path, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
api('/admin/api/settings').then(function (r) {
|
||||||
|
if (r.status !== 200) { showLock(); return; }
|
||||||
|
return r.json();
|
||||||
|
}).then(function (s) {
|
||||||
|
if (!s) return;
|
||||||
|
document.getElementById('rl-burst').value = s.rate_limit_burst;
|
||||||
|
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
|
||||||
|
document.getElementById('max-content').value = s.max_content_bytes;
|
||||||
|
document.getElementById('default-expiry').value = s.default_expiry;
|
||||||
|
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
|
||||||
|
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLock() {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
sessionStorage.removeItem(KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
// #99: don't persist the key until the server accepts it
|
||||||
|
api('/admin/api/settings', { headers: { 'X-Admin-Key': keyInput.value } }).then(function (r) {
|
||||||
|
if (r.status === 200) {
|
||||||
|
sessionStorage.setItem(KEY, keyInput.value);
|
||||||
|
status.textContent = '✓';
|
||||||
|
keyInput.value = '';
|
||||||
|
loadSettings();
|
||||||
|
} else {
|
||||||
|
status.textContent = 'invalid key';
|
||||||
|
showLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var body = {
|
||||||
|
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
|
||||||
|
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
|
||||||
|
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
|
||||||
|
default_expiry: document.getElementById('default-expiry').value,
|
||||||
|
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
|
||||||
|
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
|
||||||
|
};
|
||||||
|
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
|
||||||
|
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
|
||||||
|
if (r.status !== 200) showLock();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #112: always show the lock on fresh load — do not auto-restore the
|
||||||
|
// panel from a stale sessionStorage key. The key is only written after a
|
||||||
|
// successful unlock (above) so in-page actions still work within this visit.
|
||||||
|
})();
|
||||||
@@ -0,0 +1,884 @@
|
|||||||
|
/* Palette UI tokens (mirrors sketches/themes/tokens.css, midnight approved preset) */
|
||||||
|
:root {
|
||||||
|
--bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52;
|
||||||
|
--muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8;
|
||||||
|
--accent: #C4A8F0; --border: #42355C;
|
||||||
|
--radius-lg: 20px; --radius: 10px; --radius-sm: 999px;
|
||||||
|
--chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23C8C8D4' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: select chevron stroke (dark presets) */
|
||||||
|
--chevron-fg: #C8C8D4;
|
||||||
|
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
/* semantic status colors (dark preset values; light presets override below) */
|
||||||
|
:root {
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #241B30; /* text placed on accent-colored backgrounds */
|
||||||
|
}
|
||||||
|
[data-preset="smooth"] {
|
||||||
|
--bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6;
|
||||||
|
--muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36;
|
||||||
|
--accent: #7A7796; --border: #DAD7E6;
|
||||||
|
--ok: #456F45; --warn: #7A5E1B; --err: #9E4054;
|
||||||
|
--on-accent: #F6F5FA;
|
||||||
|
--chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */
|
||||||
|
--chevron-fg: #555560;
|
||||||
|
}
|
||||||
|
[data-preset="pastel-lavender"] {
|
||||||
|
--bg: #e6e0f5; --surface: #f1edfa; --surface-2: #cbb8e7;
|
||||||
|
--muted: #a99cc9; --muted-fg: #5f5390; --fg: #3E3059;
|
||||||
|
--accent: #806bb8; --border: #c4b6e0;
|
||||||
|
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
|
||||||
|
--on-accent: #f1edfa;
|
||||||
|
--chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */
|
||||||
|
--chevron-fg: #555560;
|
||||||
|
}
|
||||||
|
[data-preset="pastel-peach"] {
|
||||||
|
--bg: #ffe0d6; --surface: #fff0ea; --surface-2: #ffc4a8;
|
||||||
|
--muted: #d9a08c; --muted-fg: #7a4632; --fg: #4F2318;
|
||||||
|
--accent: #f9826c; --border: #ffc9b5;
|
||||||
|
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
|
||||||
|
--on-accent: #4F2318;
|
||||||
|
--chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */
|
||||||
|
--chevron-fg: #555560;
|
||||||
|
}
|
||||||
|
[data-preset="pastel-cloud"] {
|
||||||
|
--bg: #fff0f6; --surface: #fff7fb; --surface-2: #ffc8dd;
|
||||||
|
--muted: #d9b9c9; --muted-fg: #7d5670; --fg: #4A3355;
|
||||||
|
--accent: #a2d2ff; --border: #ffccd9;
|
||||||
|
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
|
||||||
|
--on-accent: #274a6b;
|
||||||
|
--chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */
|
||||||
|
--chevron-fg: #555560;
|
||||||
|
}
|
||||||
|
/* matching light/dark counterparts: same hue family, flipped lightness roles */
|
||||||
|
/* Midnight Light: light counterpart of midnight (violet family) */
|
||||||
|
[data-preset="midnight-light"] {
|
||||||
|
--bg: #F3EFF9; --surface: #FAF8FD; --surface-2: #E1D9EF;
|
||||||
|
--muted: #A99CC9; --muted-fg: #5F5390; --fg: #2B2140;
|
||||||
|
--accent: #7B5FC0; --border: #D8CEEC;
|
||||||
|
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
|
||||||
|
--on-accent: #FAF8FD;
|
||||||
|
--chevron: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23555560' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); /* #136: light presets use a dark stroke */
|
||||||
|
--chevron-fg: #555560;
|
||||||
|
}
|
||||||
|
/* Smooth Dark: dark counterpart of smooth (muted slate-violet family) */
|
||||||
|
[data-preset="smooth-dark"] {
|
||||||
|
--bg: #23222E; --surface: #2C2B3A; --surface-2: #3B3A4D;
|
||||||
|
--muted: #7A7796; --muted-fg: #B5B1C9; --fg: #E8E7F0;
|
||||||
|
--accent: #A9A6C4; --border: #45435A;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #23222E;
|
||||||
|
}
|
||||||
|
/* Pastel Lavender Dark: dark counterpart (lavender family) */
|
||||||
|
[data-preset="pastel-lavender-dark"] {
|
||||||
|
--bg: #2A2340; --surface: #352C50; --surface-2: #4A3E6B;
|
||||||
|
--muted: #8F7FB8; --muted-fg: #CBBEE8; --fg: #EFEAF8;
|
||||||
|
--accent: #B39DE4; --border: #4E4272;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #2A2340;
|
||||||
|
}
|
||||||
|
/* Pastel Peach Dark: dark counterpart (peach family) */
|
||||||
|
[data-preset="pastel-peach-dark"] {
|
||||||
|
--bg: #3A241C; --surface: #4A2F24; --surface-2: #654234;
|
||||||
|
--muted: #B07A64; --muted-fg: #F0C4B0; --fg: #FBEBE2;
|
||||||
|
--accent: #F9826C; --border: #75503F;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #3A241C;
|
||||||
|
}
|
||||||
|
/* Pastel Cloud Dark: dark counterpart (pink cloud family, blue accent) */
|
||||||
|
[data-preset="pastel-cloud-dark"] {
|
||||||
|
--bg: #33222E; --surface: #402C3A; --surface-2: #583D50;
|
||||||
|
--muted: #A87E97; --muted-fg: #E5C2D6; --fg: #FAEEF5;
|
||||||
|
--accent: #A2D2FF; --border: #644759;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #33222E;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 24.2px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 20px;
|
||||||
|
padding: 0 22px; height: 76px;
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.logo { font-weight: 700; font-size: 27.6px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); }
|
||||||
|
.logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; }
|
||||||
|
.topbar nav { display: flex; gap: 4px; }
|
||||||
|
.topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: var(--radius); font-size: 23.2px; }
|
||||||
|
.topbar nav a:hover { background: var(--surface-2); color: var(--fg); }
|
||||||
|
.topbar nav a.on { background: var(--accent); color: var(--bg); }
|
||||||
|
.topbar .spacer { flex: 1; }
|
||||||
|
.kbd { font-family: var(--font-mono); font-size: 18.9px; border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 6px; color: var(--muted-fg); }
|
||||||
|
|
||||||
|
.float {
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(0, 0, 0, .10),
|
||||||
|
0 2px 6px rgba(0, 0, 0, .08),
|
||||||
|
0 8px 24px rgba(0, 0, 0, .07);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* new paste page */
|
||||||
|
.deck {
|
||||||
|
display: grid; grid-template-columns: 1fr 300px; gap: 16px;
|
||||||
|
padding: 16px 20px 20px; height: calc(100vh - 76px);
|
||||||
|
max-width: 1400px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; }
|
||||||
|
.side-section { padding: 14px 16px; flex-shrink: 0; }
|
||||||
|
.side-section h3 { font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
|
||||||
|
.pane-l-col { display: flex; flex-direction: column; gap: 14px; min-height: 0; }
|
||||||
|
.pane-l-head { flex-shrink: 0; }
|
||||||
|
.editor-head {
|
||||||
|
display: flex; align-items: stretch; gap: 12px; padding: 12px 16px;
|
||||||
|
}
|
||||||
|
/* #147: title lives in its own pill; select+refresh stay paired in a second pill */
|
||||||
|
.editor-head-title,
|
||||||
|
.editor-head-settings {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.editor-head-title { flex: 1; min-width: 0; }
|
||||||
|
.editor-head-settings { flex: 0 0 fit-content; }
|
||||||
|
.editor-head-title:focus-within { border-color: var(--accent); }
|
||||||
|
.editor-head input {
|
||||||
|
border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 23.2px; flex: 1;
|
||||||
|
}
|
||||||
|
.editor-head select {
|
||||||
|
border: 1px solid var(--border); background-color: var(--surface-2); color: var(--muted-fg);
|
||||||
|
border-radius: var(--radius); padding: 4px 10px; font: inherit; font-size: 21.6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
/* shared code line metrics (#50): gutter + code must share one line box */
|
||||||
|
:root { --code-lh: 1.7; --code-fs: 21.6px; }
|
||||||
|
|
||||||
|
.editor-wrap { flex: 1; display: flex; min-height: 0; }
|
||||||
|
.gutter {
|
||||||
|
padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono);
|
||||||
|
font-size: var(--code-fs); line-height: var(--code-lh); user-select: none; white-space: pre; overflow: hidden;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.editor {
|
||||||
|
flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 21.6px; line-height: 1.7;
|
||||||
|
white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg);
|
||||||
|
resize: none; width: 100%;
|
||||||
|
}
|
||||||
|
.editor::placeholder { color: var(--muted); }
|
||||||
|
.actionbar {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
}
|
||||||
|
.actionbar .btn { padding: 10px 26px; }
|
||||||
|
.btn {
|
||||||
|
background: var(--accent); color: var(--bg); border: none; cursor: pointer;
|
||||||
|
padding: 8px 18px; border-radius: var(--radius); font: inherit; font-size: 22.4px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.08); }
|
||||||
|
.hint { font-size: 20.7px; color: var(--muted-fg); }
|
||||||
|
.hint b { color: var(--fg); font-weight: 550; }
|
||||||
|
.seg { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: var(--radius); cursor: pointer; font-size: 22.4px; }
|
||||||
|
.seg label:hover { background: var(--surface-2); }
|
||||||
|
.seg input { accent-color: var(--accent); }
|
||||||
|
.toggle { display: flex; align-items: center; gap: 8px; font-size: 22.4px; cursor: pointer; padding: 5px 8px; border-radius: var(--radius); }
|
||||||
|
.toggle:hover { background: var(--surface-2); }
|
||||||
|
.toggle input { accent-color: var(--accent); }
|
||||||
|
.deck .row { display: flex; justify-content: space-between; align-items: center; font-size: 22.4px; padding: 4px 0; }
|
||||||
|
.row input[type="text"] {
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; background: var(--bg);
|
||||||
|
color: var(--fg); font: inherit; font-size: 21.6px; width: 130px;
|
||||||
|
}
|
||||||
|
/* #168: compact side notification pill (paste-created feedback) */
|
||||||
|
.created-pill {
|
||||||
|
position: fixed; right: 12px; bottom: 12px; z-index: 200;
|
||||||
|
background: var(--surface-2); color: var(--ok); border: 1px solid var(--ok);
|
||||||
|
border-radius: var(--radius-sm); padding: 2px 8px; font-size: 11.5px;
|
||||||
|
opacity: 0; pointer-events: none; transform: translateY(6px);
|
||||||
|
transition: opacity .25s ease, transform .25s ease;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.created-pill.show { opacity: 1; transform: translateY(0); }
|
||||||
|
@media (max-width: 640px) { .created-pill { right: 12px; bottom: 12px; } }
|
||||||
|
|
||||||
|
/* paste view */
|
||||||
|
.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
|
||||||
|
.meta-bar h1 { font-size: 29.2px; font-weight: 600; }
|
||||||
|
.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: var(--radius-sm); }
|
||||||
|
.tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; }
|
||||||
|
.paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
|
||||||
|
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; word-break: normal; overflow-wrap: anywhere; }
|
||||||
|
/* #222: .float already draws the border + radius and clips corners; the pill's own
|
||||||
|
tighter border curve was getting clipped thin at the corners. Let .float be the
|
||||||
|
only border/paint surface for the details pill (collapsed and expanded). */
|
||||||
|
.stats-pill { border: 0; border-radius: 0; overflow: hidden; }
|
||||||
|
.stats-head { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; border-bottom: 1px solid var(--border); color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 14px 18px; cursor: pointer; text-align: left; }
|
||||||
|
.stats-head:hover { color: var(--fg); background: var(--surface-2); }
|
||||||
|
.stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; }
|
||||||
|
.stats-pill.open .stats-chev { transform: rotate(180deg); }
|
||||||
|
.stats-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: .01em; }
|
||||||
|
@media (max-width: 640px) { .stats-summary { white-space: normal; word-break: break-word; } }
|
||||||
|
.stats-body { /* #169: divider under the dropdown title is the head's own persistent border-bottom */ }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; padding: 12px 16px; font-size: 20.7px; }
|
||||||
|
.stats-k { color: var(--muted-fg); }
|
||||||
|
.stats-v { color: var(--fg); word-break: break-all; }
|
||||||
|
.meta-bar .spacer { flex: 1; }
|
||||||
|
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 5px 12px; font: inherit; font-size: 21.6px; cursor: pointer; text-decoration: none; }
|
||||||
|
.iconbtn.gear { display: inline-flex; align-items: center; padding: 5px 9px; }
|
||||||
|
.iconbtn.gear svg { width: 22px; height: 22px; }
|
||||||
|
/* #127: gear highlights like selected nav tabs; dark toggle button */
|
||||||
|
.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
|
.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 22px; height: 22px; }
|
||||||
|
.iconbtn.dark-toggle .icon-moon { display: none; }
|
||||||
|
.iconbtn.dark-toggle .icon-sun { display: inline-block; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-sun { display: none; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; }
|
||||||
|
/* #127: gear highlights like selected nav tabs; dark toggle button */
|
||||||
|
.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
|
.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 22px; height: 22px; }
|
||||||
|
.iconbtn.dark-toggle .icon-moon { display: none; }
|
||||||
|
.iconbtn.dark-toggle .icon-sun { display: inline-block; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-sun { display: none; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; }
|
||||||
|
.settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
|
||||||
|
.settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; }
|
||||||
|
.settings-section-title { color: var(--fg); font-size: 24px; margin-bottom: 6px; }
|
||||||
|
/* theme switcher cards (#100) */
|
||||||
|
.theme-grid { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; }
|
||||||
|
|
||||||
|
/* #161: more breathing room between settings sections */
|
||||||
|
/* #207 r2: owner wants the toggle left aligned (revert #197 centering) */
|
||||||
|
#settings-dark-toggle { margin-bottom: 20px; }
|
||||||
|
.settings-body h3 { margin-top: 28px; }
|
||||||
|
.settings-body .theme-grid { margin-bottom: 8px; }
|
||||||
|
.theme-card {
|
||||||
|
display: flex; flex-direction: column; gap: 10px; align-items: flex-start;
|
||||||
|
padding: 14px 16px; min-width: 180px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--fg); font: inherit; font-size: 20px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-card:hover { border-color: var(--muted); }
|
||||||
|
.theme-card[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.theme-card .swatches { display: flex; gap: 4px; }
|
||||||
|
.theme-card .swatch { width: 26px; height: 26px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); }
|
||||||
|
.theme-name { font-weight: 600; }
|
||||||
|
.iconbtn:hover { color: var(--fg); border-color: var(--muted); }
|
||||||
|
/* #130: line wrap toggle, accent when active like other toggles */
|
||||||
|
/* #166: wrap toggle must show its state: neutral when off, accent when on.
|
||||||
|
The .btn base is accent filled, which made on and off look identical. */
|
||||||
|
.btn-icon.wrap-toggle { background: var(--surface-2); color: var(--muted-fg); font-size: 21.6px; font-weight: inherit; border: 1px solid var(--border); }
|
||||||
|
.btn-icon.wrap-toggle:hover { color: var(--fg); border-color: var(--muted-fg); filter: none; }
|
||||||
|
.iconbtn.wrap-toggle[aria-pressed="true"],
|
||||||
|
.btn-icon.wrap-toggle[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
|
/* #152: wrap ON must break long unbroken tokens mid-word and allow no horizontal scrolling */
|
||||||
|
html[data-wrap] .codebody { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; overflow-x: hidden; }
|
||||||
|
/* #167: overflow:hidden zeroes the flex auto-minimum, so the codebody must
|
||||||
|
be told to fill the space left of the gutter or it collapses and the
|
||||||
|
gutter (flex-shrink:0) consumes the whole row */
|
||||||
|
html[data-wrap] .code .codebody { flex: 1 1 auto; min-width: 0; }
|
||||||
|
html[data-wrap] .editor { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; overflow-x: hidden; }
|
||||||
|
/* #152: with wrap on nothing may scroll horizontally, including the code container and mobile floats */
|
||||||
|
html[data-wrap] .code { overflow-x: hidden; }
|
||||||
|
html[data-wrap] .float { overflow-x: hidden; }
|
||||||
|
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
|
||||||
|
.code-head {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--border); font-size: 21.6px; color: var(--muted-fg);
|
||||||
|
}
|
||||||
|
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||||
|
.code {
|
||||||
|
font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh);
|
||||||
|
padding: 14px 0; display: flex; overflow-x: auto;
|
||||||
|
}
|
||||||
|
/* #167: the gutter must not drive the flex layout — its content width
|
||||||
|
(row count × number width) shrinks the code column, which re-wraps lines,
|
||||||
|
which grows the gutter: a feedback loop. Pin the gutter with a fixed
|
||||||
|
basis, take its width out of the negotiation, and let the codebody take
|
||||||
|
the rest. */
|
||||||
|
.code .gutter { flex: 0 0 auto; width: 3ch; min-width: 3ch; overflow: visible; }
|
||||||
|
.code .gutter { flex-shrink: 0; }
|
||||||
|
/* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */
|
||||||
|
.code .gutter { padding-top: 0; padding-bottom: 0; }
|
||||||
|
.codebody { padding: 0 18px; white-space: pre; }
|
||||||
|
/* #167: each logical line is its own block so offsetTop identifies its first visual row */
|
||||||
|
.codeline { display: block; }
|
||||||
|
/* #167 rev: gutter number spans must stack one per visual row (wrap on) */
|
||||||
|
.code .gutter .gutline { display: block; }
|
||||||
|
/* #194: codeline blocks are adjacent (no '\n' text between them), so an
|
||||||
|
empty block (blank source line) needs its own line box to stay one row */
|
||||||
|
.codeline:empty::before { content: "\200B"; }
|
||||||
|
/* syntax highlight tokens (#1) */
|
||||||
|
.tok-kw { color: #c792ea; }
|
||||||
|
.tok-str { color: #a5e075; }
|
||||||
|
.tok-num { color: #f78c6c; }
|
||||||
|
.tok-com { color: #6a737d; font-style: italic; }
|
||||||
|
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 20.7px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* history */
|
||||||
|
.page { max-width: 1200px; margin: 0 auto; padding: 20px 20px 28px; display: flex; flex-direction: column; gap: 16px; min-height: calc(100vh - 76px); }
|
||||||
|
.pager { position: sticky; bottom: 0; margin-top: auto; z-index: 5; }
|
||||||
|
.head-row { display: flex; align-items: baseline; gap: 14px; }
|
||||||
|
.head-row h1 { font-size: 34.5px; font-weight: 600; }
|
||||||
|
.search {
|
||||||
|
display: flex; align-items: center; gap: 8px; background: var(--surface);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 14px; width: 260px;
|
||||||
|
}
|
||||||
|
.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 22.4px; width: 100%; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 22.4px; table-layout: fixed; }
|
||||||
|
th {
|
||||||
|
text-align: left; font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em;
|
||||||
|
color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600;
|
||||||
|
}
|
||||||
|
td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: clip; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
td:has(> a.slug), td:has(> .slug) { overflow: visible; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr.row { cursor: pointer; }
|
||||||
|
tr.row:hover td { background: var(--surface-2); }
|
||||||
|
tr.row:hover td a.slug { color: var(--accent); }
|
||||||
|
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
|
||||||
|
td a.slug:hover { color: var(--accent); }
|
||||||
|
/* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */
|
||||||
|
td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); }
|
||||||
|
.badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; }
|
||||||
|
.badge.lock { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.dim { color: var(--muted-fg); white-space: nowrap; }
|
||||||
|
.pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 21.6px; color: var(--muted-fg); }
|
||||||
|
.pager .pg { display: flex; gap: 6px; }
|
||||||
|
.pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 4px 11px; font: inherit; font-size: 21.6px; cursor: pointer; }
|
||||||
|
.pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); }
|
||||||
|
.pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
|
.pager button:disabled { opacity: .4; cursor: default; }
|
||||||
|
.empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 22.4px; }
|
||||||
|
|
||||||
|
/* unlock */
|
||||||
|
.center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); }
|
||||||
|
.center .float { width: 400px; max-width: 100%; }
|
||||||
|
.inner { padding: 28px; text-align: center; }
|
||||||
|
.lockring {
|
||||||
|
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
|
||||||
|
background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 41.4px;
|
||||||
|
}
|
||||||
|
.inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
|
||||||
|
/* #173: missing paste message box */
|
||||||
|
.notfound-err { color: var(--err); }
|
||||||
|
.notfound-card .btn { display: block; margin-top: 10px; }
|
||||||
|
.pwinput {
|
||||||
|
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
}
|
||||||
|
.pwinput:focus { border-color: var(--accent); }
|
||||||
|
.center .btn { width: 100%; margin-top: 12px; }
|
||||||
|
.err { display: none; margin-top: 12px; font-size: 21.6px; color: #ff8fa3; }
|
||||||
|
.center .foot { font-size: 20.7px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 4px 8px; font-size: 24.2px; line-height: 1; overflow: visible;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 40px; height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* selection controls: pill-style selected states (#14) */
|
||||||
|
.seg label, .toggle {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background .12s ease, border-color .12s ease, color .12s ease;
|
||||||
|
}
|
||||||
|
.seg label:hover, .toggle:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.seg label:has(input:checked),
|
||||||
|
.toggle:has(input:checked) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--fg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.seg label:has(input:focus-visible),
|
||||||
|
.toggle:has(input:focus-visible) {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.seg input, .toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; }
|
||||||
|
|
||||||
|
/* toast (#19, #168): small pill anchored to the corner, out of content flow */
|
||||||
|
.toast {
|
||||||
|
position: fixed; right: 16px; bottom: 16px;
|
||||||
|
background: var(--surface-2); color: var(--fg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); padding: 6px 12px; font-size: 13px;
|
||||||
|
opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; z-index: 200;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translateY(0); }
|
||||||
|
/* status variants (#16) */
|
||||||
|
.toast.success { border-color: var(--ok); color: var(--ok); }
|
||||||
|
.toast.error { border-color: var(--err); color: var(--err); }
|
||||||
|
|
||||||
|
/* result box color-coding (#105): colored left border + text tint per status */
|
||||||
|
.result-ok, .result-err, .result-warn { border-left: 4px solid transparent; padding-left: 10px; }
|
||||||
|
.result-ok { border-left-color: var(--ok); color: var(--ok); }
|
||||||
|
.result-err { border-left-color: var(--err); color: var(--err); }
|
||||||
|
.result-warn { border-left-color: var(--warn); color: var(--warn); }
|
||||||
|
.result-ok a { color: var(--ok); }
|
||||||
|
|
||||||
|
/* protection section rhythm (#20) */
|
||||||
|
.protect { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.protect .pw-row { padding: 2px 8px 4px; }
|
||||||
|
|
||||||
|
/* submenu number boxes (#157): styled number inputs + unit selects,
|
||||||
|
padded + indented to line up with parent option labels */
|
||||||
|
.submenu {
|
||||||
|
margin: 6px 0 4px 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.exp-group { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.num-input {
|
||||||
|
width: 72px;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--surface-2); color: var(--fg);
|
||||||
|
font: inherit; font-size: 21.6px; padding: 6px 10px; outline: none;
|
||||||
|
-moz-appearance: textfield; appearance: textfield;
|
||||||
|
}
|
||||||
|
.num-input::-webkit-outer-spin-button,
|
||||||
|
.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.num-input:focus { border-color: var(--accent); }
|
||||||
|
.unit-select {
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background-color: var(--surface-2); color: var(--muted-fg);
|
||||||
|
font: inherit; font-size: 21.6px; padding: 6px 28px 6px 10px;
|
||||||
|
cursor: pointer; outline: none;
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
background-image: var(--chevron);
|
||||||
|
background-repeat: no-repeat; background-position: right 10px center;
|
||||||
|
}
|
||||||
|
.unit-select:focus { border-color: var(--accent); }
|
||||||
|
.readable-group { gap: 8px; }
|
||||||
|
.readable-label { font-size: 19px; color: var(--muted-fg); white-space: nowrap; }
|
||||||
|
.pw-field {
|
||||||
|
display: flex; align-items: center; gap: 2px; width: 100%;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
|
||||||
|
}
|
||||||
|
.pw-field:focus-within { border-color: var(--accent); }
|
||||||
|
.pw-field input {
|
||||||
|
flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--fg);
|
||||||
|
font: inherit; font-size: 21.6px; padding: 7px 12px; letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
.pw-field input::placeholder { color: var(--muted); letter-spacing: normal; }
|
||||||
|
.pw-field .reveal {
|
||||||
|
background: none; border: none; color: var(--muted-fg); cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 0 10px; height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pw-field .reveal:hover { color: var(--fg); }
|
||||||
|
.pw-field .reveal .eye-slash { display: none; }
|
||||||
|
.pw-field .reveal.off .eye-slash { display: block; }
|
||||||
|
.pw-field svg { width: 20px; height: 20px; display: block; }
|
||||||
|
|
||||||
|
/* custom URL input (#22) */
|
||||||
|
.deck .row input[type="text"] { width: 100%; }
|
||||||
|
.custom-input {
|
||||||
|
display: block; width: 100%;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 12px;
|
||||||
|
background: var(--bg); color: var(--fg); font: inherit; font-size: 21.6px; outline: none;
|
||||||
|
}
|
||||||
|
.custom-input:focus { border-color: var(--accent); }
|
||||||
|
.custom-input::placeholder { color: var(--muted); }
|
||||||
|
|
||||||
|
/* btn-icon svg (#24) */
|
||||||
|
.btn-icon svg { width: 20px; height: 20px; display: block; }
|
||||||
|
|
||||||
|
/* search spinner (#32) */
|
||||||
|
.search-spinner {
|
||||||
|
width: 16px; height: 16px; flex-shrink: 0;
|
||||||
|
border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%;
|
||||||
|
animation: spin .8s linear infinite; visibility: hidden;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* unlock redesign (#27) */
|
||||||
|
.unlock-card .pw-field { margin: 18px 0 4px; text-align: left; }
|
||||||
|
.unlock-card .pw-field input { text-align: left; }
|
||||||
|
.unlock-err { margin-top: 10px; font-size: 20.7px; color: #ff8fa3; }
|
||||||
|
|
||||||
|
/* paste name under slug pill in Paste column (#43) */
|
||||||
|
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.paste-sub.dim { color: var(--muted); }
|
||||||
|
td a.slug.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; }
|
||||||
|
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
|
||||||
|
td .id-link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* sortable column headers (#42) */
|
||||||
|
th.sortable { cursor: pointer; user-select: none; }
|
||||||
|
th.sortable:hover { color: var(--fg); }
|
||||||
|
th.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
|
||||||
|
/* #209: arrow sits RIGHT of the label, vertically centered, and absolutely
|
||||||
|
positioned so it never shifts the label text; label aligns with column contents. */
|
||||||
|
th.sortable .sort-ind {
|
||||||
|
/* #209 r2: inline right after the label text with a fixed gap, not pinned to the column edge */
|
||||||
|
display: inline-block; width: 0; height: 0; margin-left: 6px; vertical-align: middle;
|
||||||
|
border-left: 5px solid transparent; border-right: 5px solid transparent;
|
||||||
|
}
|
||||||
|
th.sorted.asc .sort-ind { border-bottom: 6px solid var(--accent); }
|
||||||
|
th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
|
||||||
|
|
||||||
|
/* #102: small unobtrusive admin link on the settings page */
|
||||||
|
a.admin-link { color: var(--muted-fg); font-size: 15px; text-decoration: none; }
|
||||||
|
a.admin-link:hover { color: var(--fg); text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Consistency audit (#18) — shared tokens across inputs, buttons,
|
||||||
|
headings. Visual-only, no behavior change.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* shared text-input treatment: .search input, .pw-field input, #title, #custom */
|
||||||
|
.search input,
|
||||||
|
.pw-field input,
|
||||||
|
.editor-head input#title,
|
||||||
|
.custom-input,
|
||||||
|
.row input[type="text"] {
|
||||||
|
font-size: 21.6px;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.search input::placeholder,
|
||||||
|
.pw-field input::placeholder,
|
||||||
|
.editor-head input#title::placeholder,
|
||||||
|
.custom-input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.custom-input { border-radius: var(--radius); }
|
||||||
|
|
||||||
|
/* buttons (incl. icon-only variants) share one radius + focus ring */
|
||||||
|
.btn-icon, .iconbtn {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: color .12s ease, border-color .12s ease, background .12s ease;
|
||||||
|
}
|
||||||
|
.iconbtn:focus-visible, .btn:focus-visible, .btn-icon:focus-visible, .pager button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* in-place copy success feedback (#53) */
|
||||||
|
.iconbtn.ok, .btn.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
border-color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* headings: unified treatment (mirrors .side-section h3) */
|
||||||
|
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* consistent card padding scale: 12px 18px for wide card heads/bodies */
|
||||||
|
.settings-head { padding: 12px 18px; }
|
||||||
|
.settings-body { padding: 16px 18px; }
|
||||||
|
|
||||||
|
/* topbar: Git external-link arrow (#56) */
|
||||||
|
.topbar nav a .ext { width: 14px; height: 14px; margin-left: 4px; opacity: .55; vertical-align: -1px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body { font-size: 16px; }
|
||||||
|
|
||||||
|
/* topbar: tighten so logo + nav + gear fit */
|
||||||
|
.topbar { gap: 6px; padding: 0 10px; height: 56px; }
|
||||||
|
.logo { font-size: 17px; white-space: nowrap; }
|
||||||
|
.logo em { display: none; }
|
||||||
|
.topbar nav { gap: 2px; flex-shrink: 0; }
|
||||||
|
.topbar nav a { padding: 4px 6px; font-size: 15px; }
|
||||||
|
.iconbtn.gear { padding: 4px 7px; flex-shrink: 0; }
|
||||||
|
.iconbtn.gear svg { width: 18px; height: 18px; }
|
||||||
|
.iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 18px; height: 18px; }
|
||||||
|
.iconbtn.dark-toggle span { display: none; }
|
||||||
|
.iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 18px; height: 18px; }
|
||||||
|
.iconbtn.dark-toggle span { display: none; }
|
||||||
|
|
||||||
|
/* new paste: stack editor above sidebar, natural page height */
|
||||||
|
.deck {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: auto; min-height: calc(100vh - 56px);
|
||||||
|
padding: 12px; gap: 12px;
|
||||||
|
}
|
||||||
|
.pane-l-col { order: 0; }
|
||||||
|
.pane-r { order: 1; overflow-y: visible; }
|
||||||
|
.editor-wrap { min-height: 45vh; }
|
||||||
|
.editor { font-size: 15px; }
|
||||||
|
.gutter { font-size: 15px; }
|
||||||
|
.editor-head { flex-wrap: wrap; }
|
||||||
|
.editor-head-title { flex: 1 1 100%; }
|
||||||
|
.editor-head input { min-width: 0; font-size: 16px; }
|
||||||
|
.editor-head select { max-width: 120px; font-size: 14px; }
|
||||||
|
.editor-head-title, .editor-head-settings { padding: 4px 8px; }
|
||||||
|
.actionbar { flex-wrap: wrap; }
|
||||||
|
.actionbar .btn { padding: 12px 22px; }
|
||||||
|
.hint { font-size: 13px; }
|
||||||
|
|
||||||
|
/* history: horizontal-scroll table inside its card */
|
||||||
|
.page { padding: 12px; }
|
||||||
|
.head-row { flex-wrap: wrap; }
|
||||||
|
.head-row h1 { font-size: 22px; }
|
||||||
|
.search { width: 100%; }
|
||||||
|
.search input { font-size: 16px; }
|
||||||
|
.float { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
table { min-width: 720px; }
|
||||||
|
th { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
|
||||||
|
td { padding: 8px 10px; font-size: 14px; }
|
||||||
|
.pager { flex-wrap: wrap; gap: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* paste view */
|
||||||
|
.paste-title-bar { padding: 10px 12px; gap: 8px; }
|
||||||
|
.paste-title-bar h1 { font-size: 18px; }
|
||||||
|
.slug { font-size: 13px; word-break: break-all; }
|
||||||
|
.stats-head { font-size: 13px; }
|
||||||
|
.stats-grid { font-size: 13px; padding: 10px 12px; }
|
||||||
|
.code { font-size: 13px; }
|
||||||
|
/* #167: gutter and code must share one line box at mobile width too — a
|
||||||
|
taller gutter font stacks its rows taller than the code rows and every
|
||||||
|
number drifts off its line start */
|
||||||
|
.code .gutter { font-size: 13px; }
|
||||||
|
.codebody { padding: 0 12px; }
|
||||||
|
.footnote { font-size: 12px; padding: 8px 12px; gap: 10px; }
|
||||||
|
.iconbtn { font-size: 13px; padding: 6px 10px; }
|
||||||
|
|
||||||
|
/* unlock card */
|
||||||
|
.center { height: auto; min-height: calc(100vh - 56px); padding: 16px; }
|
||||||
|
.center .float { width: 100%; }
|
||||||
|
.inner { padding: 20px 16px; }
|
||||||
|
.inner h1 { font-size: 20px; }
|
||||||
|
.inner .sub { font-size: 14px; }
|
||||||
|
.unlock-err { font-size: 13px; }
|
||||||
|
.center .foot { font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #4: can view page + can builder */
|
||||||
|
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
|
||||||
|
.can-item { padding: 12px 14px; }
|
||||||
|
.can-item-head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.can-item-head strong { flex: 1; word-break: break-all; }
|
||||||
|
.can-item-body { margin-top: 8px; }
|
||||||
|
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
|
||||||
|
/* #169: divider between the dropdown title and body must be persistent
|
||||||
|
(not hover dependent) and extend the full width of the card left and right */
|
||||||
|
.can-item-body[open] summary {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin: 0 -14px 8px;
|
||||||
|
padding: 0 14px 8px;
|
||||||
|
}
|
||||||
|
.can-item-body pre.code { margin: 8px 0 0; overflow-x: auto; }
|
||||||
|
.can-item-row { margin-top: 8px; }
|
||||||
|
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
|
||||||
|
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Styled controls (#123) — buttons, selects, checkboxes, radios
|
||||||
|
match the custom URL box house pattern: 1px var(--border) on
|
||||||
|
var(--bg), var(--radius) corners, accent border on focus, no
|
||||||
|
default browser chrome (appearance: none).
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* selects: replace native chrome with a house-styled pill */
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg) var(--chevron) no-repeat right 10px center; /* #136: themed chevron */
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
padding: 4px 30px 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .12s ease, background-color .12s ease;
|
||||||
|
}
|
||||||
|
select:hover { border-color: var(--muted-fg); background-color: var(--surface-2); }
|
||||||
|
select:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.editor-head select { padding: 4px 30px 4px 10px; }
|
||||||
|
|
||||||
|
/* checkboxes and radios: custom drawn, accent when checked */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px; height: 16px; margin: 0; flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color .12s ease, background-color .12s ease;
|
||||||
|
}
|
||||||
|
input[type="checkbox"] { border-radius: 4px; }
|
||||||
|
input[type="radio"] { border-radius: 50%; }
|
||||||
|
input[type="checkbox"]:hover,
|
||||||
|
input[type="radio"]:hover { border-color: var(--muted-fg); }
|
||||||
|
input[type="checkbox"]:checked,
|
||||||
|
input[type="radio"]:checked { background: var(--accent); border-color: var(--accent); }
|
||||||
|
input[type="checkbox"]:checked::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10'%3E%3Cpath d='M1 5l4 4 6-8' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 11px no-repeat;
|
||||||
|
}
|
||||||
|
input[type="radio"]:checked::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
input[type="checkbox"]:focus-visible,
|
||||||
|
input[type="radio"]:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* buttons: every submit-style button gets the .btn treatment,
|
||||||
|
and .btn itself gains the house surface/border/hover pattern */
|
||||||
|
button[type="submit"] {
|
||||||
|
background: var(--accent); color: var(--bg); border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius); font: inherit; font-weight: 600;
|
||||||
|
padding: 8px 18px; cursor: pointer;
|
||||||
|
transition: filter .12s ease;
|
||||||
|
}
|
||||||
|
button[type="submit"]:hover { filter: brightness(1.08); }
|
||||||
|
button[type="submit"]:focus-visible,
|
||||||
|
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
/* settings: give the Admin link clear separation from the theme grid */
|
||||||
|
.admin-link-row {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #38: attachment upload on /new + attachment display on paste view */
|
||||||
|
.side-section .file-input { display: none; }
|
||||||
|
.dropzone {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20.7px;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
background: var(--surface-2, transparent);
|
||||||
|
}
|
||||||
|
.dropzone:hover, .dropzone.dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.dropzone.dragover { border-width: 2px; }
|
||||||
|
/* #171: uploaded-file preview replaces the editor area; image fitted inside */
|
||||||
|
.file-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.editor-wrap.previewing .editor,
|
||||||
|
.editor-wrap.previewing .gutter { display: none; }
|
||||||
|
|
||||||
|
.file-chip {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 8px 14px; margin-top: 8px; background: var(--surface);
|
||||||
|
font-size: 21.6px;
|
||||||
|
}
|
||||||
|
.file-chip .file-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||||
|
.file-chip .file-chip-size { color: var(--muted-fg); font-size: 19px; }
|
||||||
|
.file-chip .file-chip-remove {
|
||||||
|
border: none; background: none; color: var(--muted-fg); cursor: pointer;
|
||||||
|
font-size: 22px; padding: 0 4px; border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); }
|
||||||
|
.attachment-bar { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
|
||||||
|
/* #221: the link pill under an image preview stays a small inline chip,
|
||||||
|
left-aligned under the image, not stretched above it. */
|
||||||
|
.attachment-bar .attachment-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 12px; align-self: flex-start;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 6px 14px; text-decoration: none; color: var(--fg);
|
||||||
|
background: var(--surface-2); font-size: 19px;
|
||||||
|
}
|
||||||
|
.attachment-bar .attachment-chip:hover { border-color: var(--accent); }
|
||||||
|
.attachment-bar .attachment-chip .attachment-name {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* #232: long filenames are truncated but the pill stays a reasonable
|
||||||
|
width; on narrow viewports it shrinks to the container instead of
|
||||||
|
overflowing the card. */
|
||||||
|
.attachment-bar .attachment-chip {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.attachment-bar .attachment-chip .attachment-name:hover {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
/* #221: images scale to fit the viewer box, aspect ratio preserved. */
|
||||||
|
.attachment-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
align-self: flex-start;
|
||||||
|
/* #229: no inner border/radius of its own; the .float wrapper already
|
||||||
|
frames the pill (inner borders get corner-clipped by overflow:hidden).
|
||||||
|
Padding on .attachment-bar gives the image breathing room from the
|
||||||
|
pill border. */
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.attachment-preview img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
|
||||||
|
.hidden { display: none; }
|
||||||
|
.can-page { max-width: 900px; width: 100%; }
|
||||||
|
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
|
||||||
|
.col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; }
|
||||||
|
.col-f { width: 100px; } .col-g { width: 190px; }
|
||||||
|
/* #210: /mine rows render a delete button cell that had no declared column,
|
||||||
|
so under table-layout:fixed it overlapped the ID column. */
|
||||||
|
.col-del { width: 64px; }
|
||||||
|
.spacer-flex { flex: 1; }
|
||||||
|
.input-num { width: 80px; }
|
||||||
|
.input-num-sm { width: 64px; }
|
||||||
|
.danger-hint { color: var(--danger, #c0392b); margin-top: 6px; }
|
||||||
|
.hint-lg { font-size: 19px; }
|
||||||
|
.mt6 { margin-top: 6px; }
|
||||||
|
.mt18 { margin-top: 18px; }
|
||||||
|
.toggle-inline { display: inline-flex; }
|
||||||
|
.wrap-normal { word-break: normal; overflow-wrap: break-word; }
|
||||||
|
.created-banner { display: block; }
|
||||||
|
/* #167: gutter rows for wrapped paste view — one row per visual code line */
|
||||||
|
.gutline { display: block; }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#7B5FC0"/>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 7a9 9 0 1 0 0 18c1.4 0 2-1 2-2s-.7-2.2-.7-3c0-1.1.9-2 2-2h3.2A2.5 2.5 0 0 0 25 15.5 9 9 0 0 0 16 7z"/>
|
||||||
|
<circle cx="11.5" cy="14.5" r="1.6" fill="#ffffff" stroke="none"/>
|
||||||
|
<circle cx="16" cy="11.5" r="1.6" fill="#ffffff" stroke="none"/>
|
||||||
|
<circle cx="20.8" cy="13.8" r="1.6" fill="#ffffff" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
@@ -0,0 +1,19 @@
|
|||||||
|
// #46: live relative-time counters: tick any [data-ts] (epoch seconds) every second
|
||||||
|
(function () {
|
||||||
|
function fmt(ts) {
|
||||||
|
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
|
||||||
|
if (s < 60) return s + 's ago';
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||||
|
return Math.floor(s / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
document.querySelectorAll('[data-ts]').forEach(el => {
|
||||||
|
const ts = parseInt(el.dataset.ts, 10);
|
||||||
|
if (!isNaN(ts)) el.textContent = fmt(ts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
document.addEventListener('DOMContentLoaded', tick);
|
||||||
|
tick();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// #139: public history table + 30s auto-refresh.
|
||||||
|
const t = PaletteTable.init({
|
||||||
|
endpoint: '/api/public',
|
||||||
|
perPage: 25,
|
||||||
|
hasPager: true,
|
||||||
|
rowHtml: it =>
|
||||||
|
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||||
|
(it.title
|
||||||
|
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
|
||||||
|
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
|
||||||
|
`</td>` +
|
||||||
|
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
|
||||||
|
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
|
||||||
|
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
|
||||||
|
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
|
||||||
|
emptyFiltered: 'No pastes match your search.',
|
||||||
|
emptyAll: 'No pastes yet. Create the first one.',
|
||||||
|
});
|
||||||
|
t.load();
|
||||||
|
setInterval(t.load, 30000); // auto-refresh history every 30s
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// #139: saved pastes table.
|
||||||
|
function toast(msg, kind) {
|
||||||
|
let t = document.querySelector('.toast');
|
||||||
|
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.remove('success', 'error');
|
||||||
|
if (kind === 'success') t.classList.add('success');
|
||||||
|
if (kind === 'error') t.classList.add('error');
|
||||||
|
t.classList.add('show');
|
||||||
|
clearTimeout(t._h);
|
||||||
|
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = PaletteTable.init({
|
||||||
|
endpoint: '/api/mine',
|
||||||
|
perPage: 25,
|
||||||
|
hasPager: true,
|
||||||
|
rowHtml: it =>
|
||||||
|
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||||
|
(it.title
|
||||||
|
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
|
||||||
|
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
|
||||||
|
`</td>` +
|
||||||
|
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
|
||||||
|
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
|
||||||
|
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
|
||||||
|
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
|
||||||
|
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">×</button></td></tr>`,
|
||||||
|
emptyFiltered: 'No pastes from this browser match your search.',
|
||||||
|
emptyAll: 'No pastes from this browser yet.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete buttons (viewer-scoped, enforced server-side #37)
|
||||||
|
document.getElementById('rows').addEventListener('click', async e => {
|
||||||
|
const del = e.target.closest('button.del');
|
||||||
|
if (!del) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
del.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
|
||||||
|
if (res.ok) { toast('Deleted', 'success'); t.load(); }
|
||||||
|
else { toast('Delete failed', 'error'); del.disabled = false; }
|
||||||
|
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
t.load();
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// #139: editor page logic (gutter, expiry, burn, attachments, create).
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const content = $('content'), gutter = $('gutter');
|
||||||
|
|
||||||
|
function updateGutter() {
|
||||||
|
const lines = content.value.split('\n').length;
|
||||||
|
let s = '';
|
||||||
|
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
|
||||||
|
gutter.textContent = s;
|
||||||
|
}
|
||||||
|
content.addEventListener('input', updateGutter);
|
||||||
|
updateGutter();
|
||||||
|
|
||||||
|
function toast(msg, kind) {
|
||||||
|
let t = document.querySelector('.toast');
|
||||||
|
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.remove('success', 'error');
|
||||||
|
if (kind === 'success') t.classList.add('success');
|
||||||
|
if (kind === 'error') t.classList.add('error');
|
||||||
|
t.classList.add('show');
|
||||||
|
clearTimeout(t._h);
|
||||||
|
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
||||||
|
}
|
||||||
|
const setHidden = (id, hid) => { const el = $(id); if (el) el.classList.toggle('hidden', hid); };
|
||||||
|
$('haspw').addEventListener('change', e => setHidden('pwrow', !e.target.checked));
|
||||||
|
$('burn').addEventListener('change', e => setHidden('burnrow', !e.target.checked));
|
||||||
|
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
|
||||||
|
setHidden('customexp-row', document.querySelector('input[name="exp"]:checked').value !== 'custom');
|
||||||
|
setHidden('customexp-err', true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// compose the expires_in Go-duration string when Custom is checked (#48).
|
||||||
|
// Returns the string, or null with an inline error shown.
|
||||||
|
function composeCustomExpiry() {
|
||||||
|
const n = parseInt($('expnum').value, 10);
|
||||||
|
const unit = $('expunit').value;
|
||||||
|
let mins = NaN;
|
||||||
|
if (n > 0) {
|
||||||
|
if (unit === 'm') mins = n;
|
||||||
|
else if (unit === 'h') mins = n * 60;
|
||||||
|
else if (unit === 'd') mins = n * 1440;
|
||||||
|
else if (unit === 'w') mins = n * 10080;
|
||||||
|
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
|
||||||
|
}
|
||||||
|
const err = $('customexp-err');
|
||||||
|
if (!(mins >= 1)) {
|
||||||
|
err.textContent = 'Enter a duration of at least 1 minute.';
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mins > 525600) { // more than 1 year
|
||||||
|
err.textContent = 'Custom expiry cannot exceed 1 year.';
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
err.classList.add('hidden');
|
||||||
|
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
|
||||||
|
const hours = Math.floor(mins / 60), rem = mins % 60;
|
||||||
|
if (rem === 0) return hours + 'h';
|
||||||
|
if (hours === 0) return rem + 'm';
|
||||||
|
return hours + 'h' + rem + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('pwreveal').addEventListener('click', () => {
|
||||||
|
const pw = $('password');
|
||||||
|
const show = pw.type === 'password';
|
||||||
|
pw.type = show ? 'text' : 'password';
|
||||||
|
$('pwreveal').classList.toggle('off', !show);
|
||||||
|
$('pwreveal').title = show ? 'Hide password' : 'Show password';
|
||||||
|
});
|
||||||
|
|
||||||
|
let guessed = ''; // last auto-detected language, '' = user override
|
||||||
|
|
||||||
|
// #105: map backend machine-readable error codes to plain-language guidance.
|
||||||
|
// Unknown codes fall back to a generic message; the technical detail stays
|
||||||
|
// in the API response for API consumers.
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
slug_taken: 'That Custom URL is already taken. Try another.',
|
||||||
|
slug_reserved: 'That Custom URL is reserved. Try another.',
|
||||||
|
slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.',
|
||||||
|
content_empty: 'Write or paste something first.',
|
||||||
|
content_too_large: 'This paste is too large. The limit is 5 MB.',
|
||||||
|
file_too_large: 'File is too large. The limit is 25 MB.',
|
||||||
|
one_file_only: 'Choose either text or a file for now.',
|
||||||
|
expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.',
|
||||||
|
rate_limited: 'Too many tries. Wait a minute and try again.',
|
||||||
|
};
|
||||||
|
const GENERIC_ERROR = 'Something went wrong. Please try again.';
|
||||||
|
|
||||||
|
function friendlyError(data) {
|
||||||
|
return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #105: color the result box by outcome — success (ok), error (err),
|
||||||
|
// warning (warn) — with a colored left border (CSS .result-ok/.result-err).
|
||||||
|
function setResultKind(kind) {
|
||||||
|
const card = $('result-card');
|
||||||
|
card.classList.remove('result-ok', 'result-err', 'result-warn');
|
||||||
|
if (kind) card.classList.add('result-' + kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(html, kind) {
|
||||||
|
$('result').innerHTML = html;
|
||||||
|
$('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || '');
|
||||||
|
setHidden('result-card', false);
|
||||||
|
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
|
||||||
|
}
|
||||||
|
function defaultFilename(lang) {
|
||||||
|
const names = {
|
||||||
|
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
|
||||||
|
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
|
||||||
|
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
|
||||||
|
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
|
||||||
|
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
|
||||||
|
ini: 'config.ini', diff: 'changes.diff',
|
||||||
|
markdown: 'notes.md', text: 'Text.txt',
|
||||||
|
};
|
||||||
|
return names[lang] || '';
|
||||||
|
}
|
||||||
|
// fill default filename when title is still blank
|
||||||
|
function maybeSetDefaultTitle(lang) {
|
||||||
|
const title = $('title');
|
||||||
|
if (lang && !title.value.trim()) {
|
||||||
|
const fn = defaultFilename(lang);
|
||||||
|
if (fn) title.value = fn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guessLang() {
|
||||||
|
if (!content.value.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/guess-language', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({content: content.value}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.language) {
|
||||||
|
guessed = data.language;
|
||||||
|
$('language').value = data.language;
|
||||||
|
maybeSetDefaultTitle(data.language);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh button: always re-detect, even if user picked something
|
||||||
|
$('reguess').addEventListener('click', guessLang);
|
||||||
|
|
||||||
|
// auto-guess when pasting into the editor
|
||||||
|
content.addEventListener('paste', () => setTimeout(guessLang, 0));
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
// #38: file attached -> file paste (1 file = 1 paste; text is ignored)
|
||||||
|
if (attachedFile) return createFilePaste();
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
content: content.value,
|
||||||
|
title: $('title').value || null,
|
||||||
|
language: $('language').value || null,
|
||||||
|
custom_slug: $('custom').value || null,
|
||||||
|
burn_after_read: $('burn').checked,
|
||||||
|
};
|
||||||
|
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
|
||||||
|
if ($('haspw').checked) body.password = $('password').value;
|
||||||
|
const exp = document.querySelector('input[name="exp"]:checked').value;
|
||||||
|
if (exp === 'custom') {
|
||||||
|
const dur = composeCustomExpiry();
|
||||||
|
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
|
||||||
|
body.expires_in = dur;
|
||||||
|
} else if (exp) {
|
||||||
|
body.expires_in = exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/pastes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
showResult(friendlyError(data), 'err');
|
||||||
|
toast('Create failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishCreate(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// shared success handling for both create paths (#38): result box, copy
|
||||||
|
// button, password auto-unlock, then redirect to the paste.
|
||||||
|
function finishCreate(data) {
|
||||||
|
const url = location.origin + '/' + (data.custom_slug || data.id);
|
||||||
|
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
|
||||||
|
$('result').dataset.token = data.deletion_token || '';
|
||||||
|
const copyBtn = document.getElementById('result-copy');
|
||||||
|
copyBtn.addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
copyBtn.classList.add('ok'); // in-place success feedback (#53)
|
||||||
|
copyBtn.textContent = 'Success!';
|
||||||
|
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
|
||||||
|
} catch(e) { toast('Copy failed', 'error'); }
|
||||||
|
});
|
||||||
|
// token carried via sessionStorage, never in the URL (#143)
|
||||||
|
const dest = '/' + data.id + '?created=1';
|
||||||
|
try { sessionStorage.setItem('deletion_token_' + data.id, data.deletion_token || ''); } catch(e) {}
|
||||||
|
// password-protected: unlock now with the password we already have (#26)
|
||||||
|
if ($('haspw').checked && data.id) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('password', $('password').value);
|
||||||
|
fd.append('next', dest);
|
||||||
|
try {
|
||||||
|
fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; });
|
||||||
|
return;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
// show the paste
|
||||||
|
location.href = dest;
|
||||||
|
}
|
||||||
|
$('create').addEventListener('click', create);
|
||||||
|
|
||||||
|
// #38: file attachment support — 1 file = 1 paste. Three input paths:
|
||||||
|
// Ctrl+V paste of a file, upload button (file picker), drag-and-drop.
|
||||||
|
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||||
|
let attachedFile = null; // the single attached File, or null
|
||||||
|
|
||||||
|
function humanSize(n) {
|
||||||
|
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
|
||||||
|
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||||
|
return n + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #171: text-file extensions -> language key for title placeholder
|
||||||
|
// convention (e.g. Python.py); fallback Text.
|
||||||
|
const LANG_BY_EXT = {
|
||||||
|
py: 'python', go: 'go', js: 'javascript', mjs: 'javascript', ts: 'typescript',
|
||||||
|
tsx: 'typescript', rs: 'rust', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp',
|
||||||
|
java: 'java', cs: 'csharp', sh: 'bash', bash: 'bash', sql: 'sql', yml: 'yaml',
|
||||||
|
yaml: 'yaml', json: 'json', html: 'html', htm: 'html', css: 'css', xml: 'xml',
|
||||||
|
php: 'php', rb: 'ruby', pl: 'perl', lua: 'lua', toml: 'toml', ini: 'ini',
|
||||||
|
diff: 'diff', md: 'markdown', txt: 'text', log: 'text',
|
||||||
|
};
|
||||||
|
const TEXT_EXTS = new Set(Object.keys(LANG_BY_EXT));
|
||||||
|
|
||||||
|
function extOf(name) {
|
||||||
|
const i = name.lastIndexOf('.');
|
||||||
|
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #171: when a file is attached it takes over the main editing area.
|
||||||
|
// Images render fitted into the editor area; text files load their
|
||||||
|
// content into the editor. Title auto-fills only if still blank (house
|
||||||
|
// rule: never overwrite a typed title).
|
||||||
|
const IMAGE_RE = /^image\//;
|
||||||
|
let previewURL = null;
|
||||||
|
|
||||||
|
// #171: CSP img-src only allows 'self' and data:, so blob: URLs are
|
||||||
|
// blocked — read the file as a data: URL via FileReader instead.
|
||||||
|
function readFileDataURL(file, cb) {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => cb(r.result);
|
||||||
|
r.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showFileInEditor(file) {
|
||||||
|
const wrap = document.querySelector('.editor-wrap');
|
||||||
|
const img = $('file-preview');
|
||||||
|
if (IMAGE_RE.test(file.type)) {
|
||||||
|
readFileDataURL(file, (dataURL) => {
|
||||||
|
previewURL = dataURL;
|
||||||
|
img.src = dataURL;
|
||||||
|
img.alt = file.name;
|
||||||
|
wrap.classList.add('previewing');
|
||||||
|
img.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.classList.remove('previewing');
|
||||||
|
img.classList.add('hidden');
|
||||||
|
if (!TEXT_EXTS.has(extOf(file.name))) return; // unknown binary: leave editor alone
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
// #184: the file replaces the main editing area, so the text always
|
||||||
|
// loads over whatever was in the editor (same rule as images, which
|
||||||
|
// hide the editor entirely).
|
||||||
|
content.value = text;
|
||||||
|
updateGutter();
|
||||||
|
guessLang();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
const wrap = document.querySelector('.editor-wrap');
|
||||||
|
const img = $('file-preview');
|
||||||
|
wrap.classList.remove('previewing');
|
||||||
|
img.classList.add('hidden');
|
||||||
|
img.removeAttribute('src');
|
||||||
|
if (previewURL) { URL.revokeObjectURL(previewURL); previewURL = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAttachedFile(file) {
|
||||||
|
if (!file) return clearAttachedFile();
|
||||||
|
if (file.size > MAX_FILE_BYTES) {
|
||||||
|
toast('File is too large. The limit is 25 MB.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attachedFile = file;
|
||||||
|
renderFileChip();
|
||||||
|
setHidden('file-text-note', false);
|
||||||
|
// #171/#184: title auto-fill — images take the file name; text files
|
||||||
|
// use the language-placeholder convention (e.g. Python.py, fallback
|
||||||
|
// Text.txt). Only when the title is still blank; never overwrite a
|
||||||
|
// typed title.
|
||||||
|
if (!$('title').value.trim()) {
|
||||||
|
if (IMAGE_RE.test(file.type)) {
|
||||||
|
$('title').value = file.name;
|
||||||
|
} else {
|
||||||
|
const lang = LANG_BY_EXT[extOf(file.name)];
|
||||||
|
if (lang) {
|
||||||
|
const fn = defaultFilename(lang);
|
||||||
|
if (fn) $('title').value = fn;
|
||||||
|
} else if (TEXT_EXTS.has(extOf(file.name))) {
|
||||||
|
$('title').value = defaultFilename('text') || 'Text.txt';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showFileInEditor(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAttachedFile() {
|
||||||
|
attachedFile = null;
|
||||||
|
$('file-input').value = '';
|
||||||
|
renderFileChip();
|
||||||
|
setHidden('file-text-note', true);
|
||||||
|
clearPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileChip() {
|
||||||
|
const slot = $('file-chip-slot');
|
||||||
|
slot.innerHTML = '';
|
||||||
|
if (!attachedFile) return;
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'file-chip';
|
||||||
|
chip.innerHTML = '<span class="file-chip-name"></span>' +
|
||||||
|
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
|
||||||
|
'<button type="button" class="file-chip-remove" title="Remove file">×</button>';
|
||||||
|
chip.querySelector('.file-chip-name').textContent = attachedFile.name;
|
||||||
|
chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile);
|
||||||
|
slot.appendChild(chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (1) file picker via the dropzone click
|
||||||
|
$('dropzone').addEventListener('click', () => $('file-input').click());
|
||||||
|
$('file-input').addEventListener('change', () => {
|
||||||
|
if ($('file-input').files.length) setAttachedFile($('file-input').files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (2) drag-and-drop onto the dropzone (and the page broadly)
|
||||||
|
const dz = $('dropzone');
|
||||||
|
['dragenter', 'dragover'].forEach(ev => {
|
||||||
|
document.addEventListener(ev, e => {
|
||||||
|
if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dz.classList.add('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(ev => {
|
||||||
|
document.addEventListener(ev, e => {
|
||||||
|
if (ev === 'drop') e.preventDefault();
|
||||||
|
if (e.target === dz || ev === 'drop') dz.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('drop', e => {
|
||||||
|
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setAttachedFile(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (3) Ctrl+V of a file anywhere on the page
|
||||||
|
document.addEventListener('paste', e => {
|
||||||
|
const files = e.clipboardData && e.clipboardData.files;
|
||||||
|
if (!files || !files.length) return; // normal text paste into the editor
|
||||||
|
e.preventDefault();
|
||||||
|
setAttachedFile(files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// file create path: POST multipart. 1 file = 1 paste: when a file is
|
||||||
|
// attached the editor text is ignored (server enforces this too).
|
||||||
|
async function createFilePaste() {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', attachedFile);
|
||||||
|
if ($('title').value) fd.append('title', $('title').value);
|
||||||
|
if ($('custom').value) fd.append('custom_slug', $('custom').value);
|
||||||
|
if ($('haspw').checked) fd.append('password', $('password').value);
|
||||||
|
if ($('burn').checked) {
|
||||||
|
fd.append('burn_after_read', 'true');
|
||||||
|
fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1));
|
||||||
|
}
|
||||||
|
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
|
||||||
|
const exp = document.querySelector('input[name="exp"]:checked').value;
|
||||||
|
if (exp === 'custom') {
|
||||||
|
const dur = composeCustomExpiry();
|
||||||
|
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
|
||||||
|
fd.append('expires_in', dur);
|
||||||
|
} else if (exp) {
|
||||||
|
fd.append('expires_in', exp);
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/pastes', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
showResult(friendlyError(data), 'err');
|
||||||
|
toast('Create failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishCreate(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset stale result state when returning via Back (bfcache) (#28)
|
||||||
|
window.addEventListener('pageshow', e => {
|
||||||
|
if (!e.persisted) return;
|
||||||
|
const rc = document.getElementById('result-card');
|
||||||
|
if (rc) rc.classList.add('hidden');
|
||||||
|
const r = document.getElementById('result');
|
||||||
|
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// #167: with line wrapping on, a logical line can occupy several visual
|
||||||
|
// lines; the gutter must show one number per VISUAL line, and each number
|
||||||
|
// must sit on the visual row where its logical line STARTS. Per-line spans
|
||||||
|
// give each logical line its own box so offsetTop order stays correct even
|
||||||
|
// when highlighting spans cross no line boundaries.
|
||||||
|
(function () {
|
||||||
|
var body = document.getElementById('codebody');
|
||||||
|
var gutter = document.getElementById('gutter');
|
||||||
|
var code = document.getElementById('code');
|
||||||
|
if (!body || !gutter || !code) return;
|
||||||
|
|
||||||
|
var wrapOn = function () {
|
||||||
|
return document.documentElement.hasAttribute('data-wrap');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrap each logical line (split on newline; spans never contain newlines
|
||||||
|
// because HighlightCode highlights per line) in a .codeline block. The
|
||||||
|
// spans are display:block, so they are joined with '' — a '\n' join leaves
|
||||||
|
// newline text nodes between blocks that pre-wrap renders as an extra line
|
||||||
|
// box per line, which would shift every following number down one row (#167,
|
||||||
|
// #205). Whitespace-only text nodes are also stripped defensively below: any
|
||||||
|
// that reach the DOM (older cached HTML, other templates) render as phantom
|
||||||
|
// rows under white-space: pre and drift the gutter.
|
||||||
|
function splitLines() {
|
||||||
|
var html = body.innerHTML;
|
||||||
|
var parts = html.split('\n');
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
out.push('<span class="codeline">' + parts[i] + '</span>');
|
||||||
|
}
|
||||||
|
body.innerHTML = out.join('');
|
||||||
|
// #205: strip whitespace-only text nodes between the .codeline blocks.
|
||||||
|
var ws = [];
|
||||||
|
for (var n = body.firstChild; n; n = n.nextSibling) {
|
||||||
|
if (n.nodeType === 3 && !/\S/.test(n.nodeValue)) ws.push(n);
|
||||||
|
}
|
||||||
|
for (var w = 0; w < ws.length; w++) body.removeChild(ws[w]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One .gutline block per visual row. The gutter must reproduce the code
|
||||||
|
// column's REAL rendered rows: each number goes on the visual row whose top
|
||||||
|
// matches its .codeline's top (a line wrapping to N rows gets its number on
|
||||||
|
// the FIRST of those rows), and filler rows pad the gaps. Geometry is
|
||||||
|
// measured, not derived from span counts or heights.
|
||||||
|
function renumber() {
|
||||||
|
var lines = body.querySelectorAll('.codeline');
|
||||||
|
if (!wrapOn() || !lines.length) {
|
||||||
|
// wrap OFF: one number per logical line (pre-existing behavior,
|
||||||
|
// including the gutter scrolling with horizontal scroll).
|
||||||
|
var s = '';
|
||||||
|
for (var k = 1; k <= lines.length; k++) s += k + '\n';
|
||||||
|
gutter.textContent = lines.length ? s.slice(0, -1) : '1';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all rects first: batching layout reads before the writes below
|
||||||
|
// avoids interleaved read/write reflows.
|
||||||
|
var bodyTop = body.getBoundingClientRect().top;
|
||||||
|
var lh = parseFloat(getComputedStyle(body).lineHeight) || 1;
|
||||||
|
var rowIdx = [];
|
||||||
|
var totalRows = 1;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var row = Math.round((lines[i].getBoundingClientRect().top - bodyTop) / lh);
|
||||||
|
if (row < 0) row = 0;
|
||||||
|
rowIdx.push(row);
|
||||||
|
if (row + 1 > totalRows) totalRows = row + 1;
|
||||||
|
}
|
||||||
|
if (totalRows < lines.length) totalRows = lines.length;
|
||||||
|
gutter.textContent = '';
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
var spans = [];
|
||||||
|
for (var r = 0; r < totalRows; r++) {
|
||||||
|
var cell = document.createElement('span');
|
||||||
|
cell.className = 'gutline';
|
||||||
|
cell.textContent = '\u00a0';
|
||||||
|
spans.push(cell);
|
||||||
|
frag.appendChild(cell);
|
||||||
|
}
|
||||||
|
gutter.appendChild(frag);
|
||||||
|
for (var j = 0; j < rowIdx.length; j++) {
|
||||||
|
spans[rowIdx[j]].textContent = String(j + 1);
|
||||||
|
}
|
||||||
|
// A wrapped line whose last visual row is only partially filled can
|
||||||
|
// settle a hair under N * line-height after the gutter is rebuilt; a
|
||||||
|
// gutter pass that changes the code column width reflows it. Verify the
|
||||||
|
// placement one frame later and re-run if any line's row moved (#167).
|
||||||
|
var placed = [];
|
||||||
|
for (var p = 0; p < rowIdx.length; p++) placed.push(rowIdx[p]);
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
var moved = false;
|
||||||
|
var bodyTop2 = body.getBoundingClientRect().top;
|
||||||
|
var lh2 = parseFloat(getComputedStyle(body).lineHeight) || lh;
|
||||||
|
var tops2 = [];
|
||||||
|
for (var q2 = 0; q2 < lines.length; q2++) tops2.push(lines[q2].getBoundingClientRect().top);
|
||||||
|
for (var q3 = 0; q3 < tops2.length; q3++) {
|
||||||
|
if (Math.round((tops2[q3] - bodyTop2) / lh2) !== placed[q3]) { moved = true; break; }
|
||||||
|
}
|
||||||
|
if (moved) renumber();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
splitLines();
|
||||||
|
renumber();
|
||||||
|
|
||||||
|
// Re-renumber on toggle (theme.js toggles data-wrap on <html>) and on any
|
||||||
|
// size change (paste of lots of text, window resize, zoom). Only width
|
||||||
|
// changes of the code column affect wrapping, so observe width only —
|
||||||
|
// height changes caused by our own renumbering must not re-trigger.
|
||||||
|
var mo = new MutationObserver(renumber);
|
||||||
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
|
||||||
|
var lastW = body.getBoundingClientRect().width;
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
var ro = new ResizeObserver(function () {
|
||||||
|
var w = body.getBoundingClientRect().width;
|
||||||
|
if (Math.abs(w - lastW) < 0.5) return;
|
||||||
|
lastW = w;
|
||||||
|
renumber();
|
||||||
|
});
|
||||||
|
ro.observe(body);
|
||||||
|
} else {
|
||||||
|
window.addEventListener('resize', renumber);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// #53/#139: paste viewer page logic. Paste id arrives via <body data-paste-id>.
|
||||||
|
function toast(msg) {
|
||||||
|
let t = document.querySelector('.toast');
|
||||||
|
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('show');
|
||||||
|
clearTimeout(t._h);
|
||||||
|
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
||||||
|
}
|
||||||
|
function toggleStats() {
|
||||||
|
const body = document.getElementById('stats-body');
|
||||||
|
const pill = document.getElementById('stats-pill');
|
||||||
|
const btn = document.getElementById('stats-toggle');
|
||||||
|
const open = body.hidden;
|
||||||
|
body.hidden = !open;
|
||||||
|
pill.classList.toggle('open', open);
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
function copyContent(btn) {
|
||||||
|
navigator.clipboard.writeText(document.getElementById('raw-content').value);
|
||||||
|
// in-place success feedback (#53)
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('ok');
|
||||||
|
btn.textContent = 'Success!';
|
||||||
|
clearTimeout(btn._okh);
|
||||||
|
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
|
||||||
|
} else {
|
||||||
|
toast('Link Copied', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function redeem() {
|
||||||
|
if (!confirm('Hard delete this paste immediately?')) return;
|
||||||
|
let tok = '';
|
||||||
|
try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {}
|
||||||
|
if (!tok) { alert('deletion token not available in this browser'); return; }
|
||||||
|
fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}})
|
||||||
|
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// wiring (moved from inline handlers for CSP #139)
|
||||||
|
var PASTE_ID = document.currentScript.getAttribute('data-paste-id');
|
||||||
|
var copyBtn = document.getElementById('copy-btn');
|
||||||
|
if (copyBtn) copyBtn.addEventListener('click', function (e) { e.preventDefault(); copyContent(copyBtn); });
|
||||||
|
var delBtn = document.getElementById('delete-btn');
|
||||||
|
|
||||||
|
// #168: show the compact paste-created pill in the bottom corner, then fade it out
|
||||||
|
const createdPill = document.getElementById('created-pill');
|
||||||
|
if (createdPill) {
|
||||||
|
requestAnimationFrame(() => createdPill.classList.add('show'));
|
||||||
|
setTimeout(() => createdPill.classList.remove('show'), 4000);
|
||||||
|
}
|
||||||
|
if (delBtn) delBtn.addEventListener('click', function (e) { e.preventDefault(); redeem(); });
|
||||||
|
var statsToggle = document.getElementById('stats-toggle');
|
||||||
|
if (statsToggle) statsToggle.addEventListener('click', toggleStats);
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// #112/#127/#139: theme switcher. Swatch colors are derived from computed CSS vars per preset.
|
||||||
|
(function () {
|
||||||
|
// #112: derive each preset's swatches from the real CSS variables in
|
||||||
|
// app.css by temporarily applying data-preset, so they can never drift.
|
||||||
|
// #127: 5 theme pairs, light swatches top row, dark bottom row.
|
||||||
|
var pairs = [
|
||||||
|
{ id: 'midnight', name: 'Midnight' },
|
||||||
|
{ id: 'smooth', name: 'Smooth' },
|
||||||
|
{ id: 'pastel-lavender', name: 'Pastel Lavender' },
|
||||||
|
{ id: 'pastel-peach', name: 'Pastel Peach' },
|
||||||
|
{ id: 'pastel-cloud', name: 'Pastel Cloud' }
|
||||||
|
];
|
||||||
|
var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent'];
|
||||||
|
|
||||||
|
function presetColors(id) {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var prev = root.getAttribute('data-preset');
|
||||||
|
root.setAttribute('data-preset', id);
|
||||||
|
var cs = getComputedStyle(root);
|
||||||
|
var colors = SWATCH_VARS.map(function (v) { return cs.getPropertyValue(v).trim(); });
|
||||||
|
if (prev === null) root.removeAttribute('data-preset'); else root.setAttribute('data-preset', prev);
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// current base pair + dark flag from the resolved data-preset.
|
||||||
|
// "midnight" is itself the dark variant, so check dark ids first.
|
||||||
|
function state() {
|
||||||
|
var p = document.documentElement.dataset.preset || 'midnight';
|
||||||
|
if (p === 'midnight' || /-dark$/.test(p)) {
|
||||||
|
return { base: p === 'midnight' ? 'midnight' : p.replace(/-dark$/, ''), dark: true };
|
||||||
|
}
|
||||||
|
return { base: p === 'midnight-light' ? 'midnight' : p, dark: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
var grid = document.getElementById('theme-grid');
|
||||||
|
var cards = {};
|
||||||
|
// #132: midnight is dark-first (root preset = midnight = dark; its light
|
||||||
|
// variant is midnight-light), the others are light-first. Resolve the
|
||||||
|
// LIGHT and DARK preset ids generically so the light swatches always
|
||||||
|
// render in the top row of every card.
|
||||||
|
function lightPreset(id) {
|
||||||
|
if (id === 'midnight') return 'midnight-light';
|
||||||
|
return id; // light-first bases use themselves as the light variant
|
||||||
|
}
|
||||||
|
function darkPreset(id) {
|
||||||
|
if (id === 'midnight') return 'midnight'; // root preset is midnight's dark
|
||||||
|
return id + '-dark';
|
||||||
|
}
|
||||||
|
pairs.forEach(function (t) {
|
||||||
|
var light = presetColors(lightPreset(t.id));
|
||||||
|
var dark = presetColors(darkPreset(t.id));
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'theme-card';
|
||||||
|
btn.setAttribute('aria-pressed', 'false');
|
||||||
|
btn.setAttribute('data-pair', t.id);
|
||||||
|
// #139: build the swatches via DOM APIs (CSSOM styles) instead of
|
||||||
|
// innerHTML strings: re-parsing serialized inline styles would trip
|
||||||
|
// style-src 'self'.
|
||||||
|
var label = document.createElement('strong');
|
||||||
|
label.textContent = t.name;
|
||||||
|
btn.appendChild(label);
|
||||||
|
[light, dark].forEach(function (colors) {
|
||||||
|
var row = document.createElement('span');
|
||||||
|
row.className = 'swatches';
|
||||||
|
colors.forEach(function (c) {
|
||||||
|
var sw = document.createElement('span');
|
||||||
|
sw.className = 'swatch';
|
||||||
|
sw.style.backgroundColor = c;
|
||||||
|
row.appendChild(sw);
|
||||||
|
});
|
||||||
|
btn.appendChild(row);
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var dark = state().dark;
|
||||||
|
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
|
||||||
|
try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
|
||||||
|
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
|
||||||
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
cards[t.id] = btn;
|
||||||
|
grid.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var s = state();
|
||||||
|
Object.keys(cards).forEach(function (k) {
|
||||||
|
cards[k].setAttribute('aria-pressed', k === s.base ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
var dt = document.getElementById('settings-dark-toggle');
|
||||||
|
if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
// topbar.js already binds every .dark-toggle (deferred, so the settings
|
||||||
|
// button exists by then). Only wire here if it somehow did not run,
|
||||||
|
// otherwise the button would toggle twice per click and never change (#197).
|
||||||
|
var dt = document.getElementById('settings-dark-toggle');
|
||||||
|
if (dt && !dt.dataset.darkWired) {
|
||||||
|
dt.addEventListener('click', function () {
|
||||||
|
var btns = document.querySelectorAll('.topbar .dark-toggle');
|
||||||
|
if (btns.length) btns[0].click(); else document.dispatchEvent(new CustomEvent('palette-darkchange'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('palette-darkchange', sync);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Shared table logic for history (/api/public) and saved (/api/mine) pages (#57).
|
||||||
|
// Provides: live search, client-side sort with indicators, row rendering via
|
||||||
|
// a page-supplied rowHtml(), pagination state, and row click-through.
|
||||||
|
const PaletteTable = (() => {
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; };
|
||||||
|
const fmtSize = n => { if (n == null) return 'none'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; };
|
||||||
|
const ago = ts => {
|
||||||
|
const s = Math.floor(Date.now()/1000) - ts;
|
||||||
|
if (s < 60) return s + 's ago';
|
||||||
|
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
||||||
|
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
||||||
|
return Math.floor(s/86400) + 'd ago';
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortVal = (it, k) => {
|
||||||
|
let v = it[k];
|
||||||
|
if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase();
|
||||||
|
if (k === 'language' || k === 'type') v = (v == null || v === '') ? 'text' : String(v).toLowerCase();
|
||||||
|
if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v;
|
||||||
|
return v == null ? null : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
function init(opts) {
|
||||||
|
// opts: {endpoint, perPage, hasPager, rowHtml(it), emptyFiltered, emptyAll}
|
||||||
|
const state = { filter: '', sortKey: null, sortDir: 1, page: 1, total: 0 };
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function sortItems(items) {
|
||||||
|
if (!state.sortKey) return items;
|
||||||
|
const k = state.sortKey, dir = state.sortDir;
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
const va = sortVal(a, k), vb = sortVal(b, k);
|
||||||
|
const na = va == null, nb = vb == null;
|
||||||
|
if (na && nb) return 0;
|
||||||
|
if (na) return 1;
|
||||||
|
if (nb) return -1;
|
||||||
|
if (va < vb) return -1 * dir;
|
||||||
|
if (va > vb) return 1 * dir;
|
||||||
|
return (a.created_at || 0) < (b.created_at || 0) ? 1 : -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(it) {
|
||||||
|
if (!state.filter) return true;
|
||||||
|
const f = state.filter.toLowerCase();
|
||||||
|
return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f) ||
|
||||||
|
(it.custom_slug || '').toLowerCase().includes(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSortIndicators() {
|
||||||
|
document.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
th.classList.toggle('sorted', th.dataset.sort === state.sortKey);
|
||||||
|
th.classList.toggle('asc', th.dataset.sort === state.sortKey && state.sortDir === 1);
|
||||||
|
th.classList.toggle('desc', th.dataset.sort === state.sortKey && state.sortDir === -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const spinner = $('search-spinner');
|
||||||
|
if (spinner) spinner.style.visibility = 'visible';
|
||||||
|
try {
|
||||||
|
const filtered = state.filter.length > 0;
|
||||||
|
const off = (state.page - 1) * opts.perPage;
|
||||||
|
const url = (filtered || state.sortKey)
|
||||||
|
? opts.endpoint + '?limit=' + (opts.fetchLimit || 100) + '&offset=0'
|
||||||
|
: opts.endpoint + '?limit=' + opts.perPage + '&offset=' + off;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
state.total = data.total;
|
||||||
|
let items = filtered ? data.items.filter(matches) : data.items;
|
||||||
|
items = sortItems(items);
|
||||||
|
|
||||||
|
const count = $('count');
|
||||||
|
if (count) count.textContent = filtered
|
||||||
|
? items.length.toLocaleString() + ' matches (of ' + state.total.toLocaleString() + ' total)'
|
||||||
|
: state.total.toLocaleString() + ' total';
|
||||||
|
|
||||||
|
const rows = $('rows'), empty = $('empty');
|
||||||
|
if (!items.length) {
|
||||||
|
rows.innerHTML = '';
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
|
||||||
|
} else {
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
rows.innerHTML = items.map(opts.rowHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pager = $('pg'), showing = $('showing');
|
||||||
|
if (opts.hasPager && pager && showing) {
|
||||||
|
const pages = Math.max(1, Math.ceil(state.total / opts.perPage));
|
||||||
|
if (filtered || state.sortKey) {
|
||||||
|
// Pagination operates over the FILTERED set (#133): never hide the bar.
|
||||||
|
const filtPages = Math.max(1, Math.ceil(items.length / opts.perPage));
|
||||||
|
if (state.page > filtPages) state.page = filtPages;
|
||||||
|
const fOff = (state.page - 1) * opts.perPage;
|
||||||
|
const view = items.slice(fOff, fOff + opts.perPage);
|
||||||
|
rows.innerHTML = view.length ? view.map(opts.rowHtml).join('') : '';
|
||||||
|
if (!view.length) {
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
|
||||||
|
}
|
||||||
|
showing.textContent = `Showing ${view.length === 0 ? 0 : fOff+1}–${fOff+view.length} of ${items.length.toLocaleString()} ${filtered ? 'matches' : 'sorted'} · page ${state.page} of ${filtPages}`;
|
||||||
|
const btns = [];
|
||||||
|
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
|
||||||
|
add('‹', state.page-1, {dis: state.page===1});
|
||||||
|
const win = new Set([1, 2, state.page-1, state.page, state.page+1, filtPages]);
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 1; i <= filtPages; i++) {
|
||||||
|
if (win.has(i)) {
|
||||||
|
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
|
||||||
|
add(String(i), i, {on: i===state.page});
|
||||||
|
last = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add('›', state.page+1, {dis: state.page===filtPages});
|
||||||
|
pager.innerHTML = btns.join('');
|
||||||
|
} else {
|
||||||
|
showing.textContent = state.total === 0 ? 'Nothing here yet' :
|
||||||
|
`Showing ${off+1}–${Math.min(off+opts.perPage, state.total)} of ${state.total.toLocaleString()} · page ${state.page} of ${pages}`;
|
||||||
|
const btns = [];
|
||||||
|
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
|
||||||
|
add('‹', state.page-1, {dis: state.page===1});
|
||||||
|
const win = new Set([1, 2, state.page-1, state.page, state.page+1, pages]);
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 1; i <= pages; i++) {
|
||||||
|
if (win.has(i)) {
|
||||||
|
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
|
||||||
|
add(String(i), i, {on: i===state.page});
|
||||||
|
last = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add('›', state.page+1, {dis: state.page===pages});
|
||||||
|
pager.innerHTML = btns.join('');
|
||||||
|
}
|
||||||
|
} else if (showing) {
|
||||||
|
showing.textContent = '';
|
||||||
|
}
|
||||||
|
renderSortIndicators();
|
||||||
|
} finally {
|
||||||
|
if (spinner) spinner.style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('thead').addEventListener('click', e => {
|
||||||
|
const th = e.target.closest('th.sortable');
|
||||||
|
if (!th) return;
|
||||||
|
const k = th.dataset.sort;
|
||||||
|
if (state.sortKey === k) { state.sortDir = -state.sortDir; } else { state.sortKey = k; state.sortDir = 1; }
|
||||||
|
state.page = 1;
|
||||||
|
renderSortIndicators();
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = $('rows');
|
||||||
|
if (rows) rows.addEventListener('click', e => {
|
||||||
|
const tr = e.target.closest('tr.row[data-href]');
|
||||||
|
if (!tr || e.target.closest('a') || e.target.closest('button')) return;
|
||||||
|
window.location.href = tr.dataset.href;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pg = $('pg');
|
||||||
|
if (pg) pg.addEventListener('click', e => {
|
||||||
|
const b = e.target.closest('button[data-p]');
|
||||||
|
if (!b || b.disabled) return;
|
||||||
|
state.page = parseInt(b.dataset.p);
|
||||||
|
load();
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = $('filter');
|
||||||
|
if (filter) filter.addEventListener('input', e => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
state.filter = e.target.value.trim();
|
||||||
|
state.page = 1;
|
||||||
|
load();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { load, state, esc, fmtSize, ago };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, esc, fmtSize, ago };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// #16/#100/#127: theme preset resolution. Runs in <head> (blocking) so the
|
||||||
|
// correct data-preset is set before first paint. The server default dark flag
|
||||||
|
// arrives via the script tag's data-default-dark attribute so no inline script is needed.
|
||||||
|
// #130: shared line wrap preference (localStorage 'palette-wrap', default off).
|
||||||
|
// preset hook (#16): ?theme= wins; else persisted choice (#100).
|
||||||
|
// #127: theme pairs - each base theme has a light and dark variant;
|
||||||
|
// palette-theme stores the base id, palette-dark the dark flag.
|
||||||
|
(function () {
|
||||||
|
var PAIRS = {
|
||||||
|
'midnight': { light: 'midnight-light', dark: 'midnight' },
|
||||||
|
'smooth': { light: 'smooth', dark: 'smooth-dark' },
|
||||||
|
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
|
||||||
|
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
|
||||||
|
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
|
||||||
|
};
|
||||||
|
function resolve(t, dark) {
|
||||||
|
for (var base in PAIRS) {
|
||||||
|
if (t === PAIRS[base].light || t === PAIRS[base].dark) return t;
|
||||||
|
if (t === base) return dark ? PAIRS[base].dark : PAIRS[base].light;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var t = new URLSearchParams(location.search).get('theme');
|
||||||
|
// precedence: URL ?theme= > stored pair+dark prefs > server default dark > dark on
|
||||||
|
var dark = (document.currentScript && document.currentScript.getAttribute('data-default-dark')) === '1';
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem('palette-dark');
|
||||||
|
if (stored !== null) dark = stored !== 'false';
|
||||||
|
} catch (e) {}
|
||||||
|
if (t) {
|
||||||
|
// URL ?theme= wins and accepts both base and variant ids; a variant id
|
||||||
|
// also sets the dark flag and normalizes t back to its base pair id.
|
||||||
|
var found = false;
|
||||||
|
for (var base in PAIRS) {
|
||||||
|
if (t === PAIRS[base].dark) { dark = true; t = base; found = true; break; }
|
||||||
|
if (t === PAIRS[base].light) { dark = false; t = base; found = true; break; }
|
||||||
|
if (t === base) { found = true; break; }
|
||||||
|
}
|
||||||
|
if (!found) t = null;
|
||||||
|
}
|
||||||
|
if (!t) {
|
||||||
|
try { t = localStorage.getItem('palette-theme'); } catch (e) {}
|
||||||
|
}
|
||||||
|
if (!PAIRS[t]) t = 'midnight';
|
||||||
|
// do not persist anything here: URL theme is a one-off override
|
||||||
|
document.documentElement.dataset.preset = dark ? PAIRS[t].dark : PAIRS[t].light;
|
||||||
|
})();
|
||||||
|
// #130: shared line wrap preference (localStorage 'palette-wrap', default off)
|
||||||
|
(function () {
|
||||||
|
function wrapOn() {
|
||||||
|
try { return localStorage.getItem('palette-wrap') === '1'; } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function wrapApply(on) {
|
||||||
|
if (on) document.documentElement.setAttribute('data-wrap', '1');
|
||||||
|
else document.documentElement.removeAttribute('data-wrap');
|
||||||
|
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
|
||||||
|
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
var s = document.getElementById('wrap-setting');
|
||||||
|
if (s) s.checked = on;
|
||||||
|
}
|
||||||
|
window.paletteWrapToggle = function () {
|
||||||
|
var on = !wrapOn();
|
||||||
|
try { localStorage.setItem('palette-wrap', on ? '1' : '0'); } catch (e) {}
|
||||||
|
wrapApply(on);
|
||||||
|
return on;
|
||||||
|
};
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
wrapApply(wrapOn());
|
||||||
|
document.querySelectorAll('.wrap-toggle').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { window.paletteWrapToggle(); });
|
||||||
|
});
|
||||||
|
var s = document.getElementById('wrap-setting');
|
||||||
|
if (s) s.addEventListener('change', function () {
|
||||||
|
if (s.checked !== wrapOn()) window.paletteWrapToggle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// #127: dark mode toggle: flips to the other variant of the active pair.
|
||||||
|
// #127: dark mode toggle - flips to the other variant of the active pair.
|
||||||
|
(function () {
|
||||||
|
var PAIRS = {
|
||||||
|
'midnight': { light: 'midnight-light', dark: 'midnight' },
|
||||||
|
'smooth': { light: 'smooth', dark: 'smooth-dark' },
|
||||||
|
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
|
||||||
|
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
|
||||||
|
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
|
||||||
|
};
|
||||||
|
var root = document.documentElement;
|
||||||
|
function state() {
|
||||||
|
var p = root.dataset.preset || 'midnight';
|
||||||
|
for (var base in PAIRS) {
|
||||||
|
if (p === PAIRS[base].dark) return { base: base, dark: true };
|
||||||
|
if (p === PAIRS[base].light) return { base: base, dark: false };
|
||||||
|
}
|
||||||
|
return { base: 'midnight', dark: true };
|
||||||
|
}
|
||||||
|
function apply() {
|
||||||
|
var s = state();
|
||||||
|
document.body.classList.toggle('dark', s.dark);
|
||||||
|
root.classList.toggle('dark', s.dark);
|
||||||
|
}
|
||||||
|
function sync(btns) {
|
||||||
|
btns.forEach(function (b) {
|
||||||
|
b.setAttribute('aria-pressed', state().dark ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var btns = document.querySelectorAll('.dark-toggle');
|
||||||
|
apply();
|
||||||
|
sync(Array.prototype.slice.call(btns));
|
||||||
|
btns.forEach(function (b) {
|
||||||
|
// mark as wired so settings.js does not add a second handler
|
||||||
|
// (#197: double-binding made the settings toggle flip twice = no-op)
|
||||||
|
b.dataset.darkWired = '1';
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
var s = state();
|
||||||
|
var dark = !s.dark;
|
||||||
|
root.dataset.preset = dark ? PAIRS[s.base].dark : PAIRS[s.base].light;
|
||||||
|
try { localStorage.setItem('palette-dark', dark ? 'true' : 'false'); } catch (e) {}
|
||||||
|
apply();
|
||||||
|
sync(Array.prototype.slice.call(btns));
|
||||||
|
document.dispatchEvent(new CustomEvent('palette-darkchange'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// #139: unlock page: password reveal toggle.
|
||||||
|
document.getElementById('pwreveal').addEventListener('click', () => {
|
||||||
|
const pw = document.getElementById('password');
|
||||||
|
const show = pw.type === 'password';
|
||||||
|
pw.type = show ? 'text' : 'password';
|
||||||
|
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="center">
|
||||||
|
<div class="float unlock-card">
|
||||||
|
<div class="inner">
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<form id="admin-key-form">
|
||||||
|
<div class="pw-field">
|
||||||
|
<input type="password" id="admin-key" placeholder="Admin key" autocomplete="off" autofocus>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit">Unlock</button>
|
||||||
|
<span id="admin-key-status"></span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="admin-panel" class="hidden">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<form id="admin-settings-form">
|
||||||
|
<table>
|
||||||
|
<tr><td>Rate-limit burst</td><td><input type="number" id="rl-burst" min="1" step="1"></td></tr>
|
||||||
|
<tr><td>Rate-limit refill per minute</td><td><input type="number" id="rl-refill" min="0.1" step="0.1"></td></tr>
|
||||||
|
<tr><td>Max content bytes</td><td><input type="number" id="max-content" min="1" step="1"></td></tr>
|
||||||
|
<tr><td>Default expiry</td><td><input type="text" id="default-expiry" placeholder="e.g. 168h, 30m, 0 = never"></td></tr>
|
||||||
|
<tr><td>Custom URL reservation days</td><td><input type="number" id="slug-days" min="1" step="1"></td></tr>
|
||||||
|
<tr><td>Burn viewer window (minutes)</td><td><input type="number" id="burn-window" min="1" step="1"></td></tr>
|
||||||
|
</table>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<span id="admin-save-status"></span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/admin.js" defer></script>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="center">
|
||||||
|
<div class="float can-page">
|
||||||
|
<div class="inner">
|
||||||
|
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
|
||||||
|
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
|
||||||
|
<p class="hint">{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}</p>
|
||||||
|
|
||||||
|
<div class="can-items">
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="can-item float">
|
||||||
|
<div class="can-item-head">
|
||||||
|
<strong>{{.Title}}</strong>
|
||||||
|
<span class="dim">{{.Size}}</span>
|
||||||
|
{{if .IsFile}}
|
||||||
|
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" download="{{.Title}}" title="Download file" aria-label="Download {{.Title}}">↓</a>
|
||||||
|
{{else}}
|
||||||
|
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" title="Raw item" aria-label="Raw {{.Title}}">⧉</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if not .IsFile}}
|
||||||
|
<details class="can-item-body">
|
||||||
|
<summary>Show content</summary>
|
||||||
|
<pre class="code"><code>{{.ContentHTML}}</code></pre>
|
||||||
|
</details>
|
||||||
|
{{else}}
|
||||||
|
<div class="hint">Attached file — use the download button above.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{define "foot"}}<script src="/static/foot.js" defer></script>{{end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="page">
|
||||||
|
<div class="head-row">
|
||||||
|
<h1>Public pastes</h1>
|
||||||
|
<span class="count" id="count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
|
||||||
|
<div class="float">
|
||||||
|
<table>
|
||||||
|
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d"><col class="col-e"><col class="col-f"><col class="col-g"></colgroup>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="size" class="sortable">Size<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="view_count" class="sortable">Views<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="created_at" class="sortable">Created<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="custom_slug" class="sortable">URL<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="id" class="sortable">ID<span class="sort-ind"></span></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty hidden" id="empty">No pastes yet. Create the first one.</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager float">
|
||||||
|
<span id="showing"></span>
|
||||||
|
<div class="pg" id="pg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/table.js"></script>
|
||||||
|
<script src="/static/history.js" defer></script>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "head"}}
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<script src="/static/theme.js" data-default-dark="{{ if defaultDark }}1{{ else }}0{{ end }}"></script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "topbar"}}
|
||||||
|
<div class="topbar">
|
||||||
|
<a class="logo" href="/history">Palette <em>/ {{ version }}</em></a>
|
||||||
|
<nav>
|
||||||
|
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
|
||||||
|
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
|
||||||
|
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</a>
|
||||||
|
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
|
||||||
|
</nav>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="iconbtn dark-toggle" id="dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
</button>
|
||||||
|
<a class="iconbtn gear{{if eq .Page "settings"}} on{{end}}" href="/settings" title="Settings" aria-label="Settings">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<script src="/static/topbar.js" defer></script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="page">
|
||||||
|
<div class="head-row">
|
||||||
|
<h1>Saved pastes</h1>
|
||||||
|
<span class="count" id="count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
|
||||||
|
<div class="float">
|
||||||
|
<table>
|
||||||
|
<colgroup><col class="col-a"><col class="col-b"><col class="col-c"><col class="col-d2"><col class="col-e"><col class="col-f"><col class="col-del"></colgroup>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="title" class="sortable">Paste<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="type" class="sortable">Type<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="size" class="sortable">Size<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="created_at" class="sortable">Created<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="custom_slug" class="sortable">URL<span class="sort-ind"></span></th>
|
||||||
|
<th data-sort="id" class="sortable">ID<span class="sort-ind"></span></th>
|
||||||
|
<th aria-label="Delete"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty hidden" id="empty">No pastes from this browser yet.</div>
|
||||||
|
</div>
|
||||||
|
<div class="pager float">
|
||||||
|
<span id="showing"></span>
|
||||||
|
<div class="pg" id="pg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/table.js"></script>
|
||||||
|
<script src="/static/mine.js" defer></script>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="deck">
|
||||||
|
<div class="pane-l-col">
|
||||||
|
<div class="float pane-l-head">
|
||||||
|
<div class="editor-head">
|
||||||
|
<div class="editor-head-title">
|
||||||
|
<input id="title" placeholder="Title">
|
||||||
|
</div>
|
||||||
|
<div class="editor-head-settings">
|
||||||
|
<select id="language">
|
||||||
|
<option value="">auto</option>
|
||||||
|
<option>go</option><option>python</option><option>javascript</option><option>typescript</option>
|
||||||
|
<option>rust</option><option>c</option><option>cpp</option><option>java</option><option>csharp</option>
|
||||||
|
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
|
||||||
|
<option>html</option><option>css</option><option>xml</option><option>php</option>
|
||||||
|
<option>ruby</option><option>perl</option><option>lua</option><option>dockerfile</option>
|
||||||
|
<option>toml</option><option>ini</option><option>diff</option>
|
||||||
|
<option>markdown</option><option>text</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>
|
||||||
|
<button type="button" class="btn btn-icon wrap-toggle" id="wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="float editor-wrap">
|
||||||
|
<div class="gutter" id="gutter">1</div>
|
||||||
|
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
|
||||||
|
<img id="file-preview" class="file-preview hidden" alt="File preview">
|
||||||
|
</div>
|
||||||
|
<div class="actionbar">
|
||||||
|
<span class="hint">Ctrl+Enter to create</span>
|
||||||
|
<div class="spacer spacer-flex"></div>
|
||||||
|
<button class="btn" id="create">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pane-r">
|
||||||
|
<div class="float side-section">
|
||||||
|
<h3>Expiry</h3>
|
||||||
|
<div class="seg">
|
||||||
|
<label><input type="radio" name="exp" value=""> Never</label>
|
||||||
|
<label><input type="radio" name="exp" value="1h"> 1 hour</label>
|
||||||
|
<label><input type="radio" name="exp" value="24h"> 1 day</label>
|
||||||
|
<label><input type="radio" name="exp" value="168h" checked> 1 week</label>
|
||||||
|
<label><input type="radio" name="exp" value="720h"> 30 days</label>
|
||||||
|
<label><input type="radio" name="exp" value="custom"> Custom</label>
|
||||||
|
</div>
|
||||||
|
<div class="pw-row submenu hidden" id="customexp-row">
|
||||||
|
<div class="exp-group">
|
||||||
|
<input type="number" class="num-input" id="expnum" min="1" placeholder="90" aria-label="Custom expiry amount">
|
||||||
|
<select id="expunit" class="unit-select">
|
||||||
|
<option value="m">minutes</option>
|
||||||
|
<option value="h" selected>hours</option>
|
||||||
|
<option value="d">days</option>
|
||||||
|
<option value="w">weeks</option>
|
||||||
|
<option value="mo">months</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hint danger-hint hidden" id="customexp-err"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="float side-section">
|
||||||
|
<h3>Protection</h3>
|
||||||
|
<div class="protect">
|
||||||
|
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
|
||||||
|
<div class="pw-row hidden" id="pwrow"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
|
||||||
|
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
|
||||||
|
<div class="pw-row submenu hidden" id="burnrow"><div class="exp-group readable-group"><label class="hint readable-label">Readable</label><input type="number" class="num-input" id="burnreads" min="1" value="1" aria-label="Times readable"><label class="hint readable-label">times</label></div></div>
|
||||||
|
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="float side-section">
|
||||||
|
<h3>Attachment</h3>
|
||||||
|
<div class="dropzone" id="dropzone" title="Paste (Ctrl+V), drop a file here, or click to browse">
|
||||||
|
Drop a file here, press Ctrl+V, or click to browse
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file-input" class="file-input" aria-label="Choose a file">
|
||||||
|
<div id="file-chip-slot"></div>
|
||||||
|
<div class="hint hint-lg hidden mt6" id="file-text-note">File pastes ignore the text editor (one file per paste)</div>
|
||||||
|
</div>
|
||||||
|
<div class="float side-section">
|
||||||
|
<h3>Custom URL</h3>
|
||||||
|
<input type="text" id="custom" class="custom-input" placeholder="my-snippet">
|
||||||
|
</div>
|
||||||
|
<div class="float side-section hidden" id="result-card">
|
||||||
|
<h3>Result</h3>
|
||||||
|
<div class="hint wrap-normal" id="result">empty</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/new.js" defer></script>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="center">
|
||||||
|
<div class="float notfound-card">
|
||||||
|
<div class="inner">
|
||||||
|
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||||
|
<h1>Paste ID not found</h1>
|
||||||
|
<p class="sub">The paste <span class="slug">/{{.ID}}</span> does not exist, has expired, or was burned.</p>
|
||||||
|
<a class="btn" href="/new">Create a new paste</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="page">
|
||||||
|
<div class="float">
|
||||||
|
<div class="paste-title-bar">
|
||||||
|
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
||||||
|
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button type="button" class="iconbtn wrap-toggle" title="Toggle line wrap" aria-pressed="false">wrap</button>
|
||||||
|
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
||||||
|
<a class="iconbtn" href="#" id="copy-btn">copy</a>
|
||||||
|
{{if .DeletionToken}}<a class="iconbtn danger" href="#" id="delete-btn">delete</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="float">
|
||||||
|
<div class="stats-pill" id="stats-pill">
|
||||||
|
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false">
|
||||||
|
<svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
<span class="stats-summary">{{.StatsSummary}}</span>
|
||||||
|
</button>
|
||||||
|
<div class="stats-body" id="stats-body" hidden>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<span class="stats-k">Type</span><span class="stats-v">{{.TypeLabel}}</span>
|
||||||
|
<span class="stats-k">Size</span><span class="stats-v">{{.SizeHuman}} ({{.LineCount}} lines)</span>
|
||||||
|
<span class="stats-k">Views</span><span class="stats-v">{{.ViewCount}}</span>
|
||||||
|
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
|
||||||
|
{{if .ExpiresAt}}<span class="stats-k">Expires</span><span class="stats-v">in {{.ExpiresIn}}</span>{{end}}
|
||||||
|
<span class="stats-k">Password</span><span class="stats-v">{{if .HasPassword}}protected{{else}}none{{end}}</span>
|
||||||
|
{{if .BurnAfterRead}}{{if .ReadsLimit}}{{with .ReadsLeftN}}<span class="stats-k">Reads left</span><span class="stats-v">{{.}} of {{$.ReadsTotal}}</span>{{end}}{{else}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}{{end}}
|
||||||
|
{{if .CustomSlug}}<span class="stats-k">Custom URL</span><span class="stats-v">/{{.CustomSlug}}</span>{{end}}
|
||||||
|
<span class="stats-k">Visibility</span><span class="stats-v">{{.Visibility}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .JustCreated}}
|
||||||
|
<div class="created-pill" id="created-pill" role="status">Paste Created</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Attachment}}
|
||||||
|
<div class="float">
|
||||||
|
<div class="attachment-bar">
|
||||||
|
{{if .AttachmentImage}}
|
||||||
|
<div class="attachment-preview"><img src="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" alt="{{.Attachment.Filename}}"></div>
|
||||||
|
{{end}}
|
||||||
|
<a class="attachment-chip" href="/f/{{.Attachment.ID}}/{{.Attachment.Filename}}" data-mime="{{.Attachment.Mime}}">
|
||||||
|
<span class="attachment-name">{{.Attachment.Filename}}</span>
|
||||||
|
<span class="attachment-size">{{.Attachment.SizeHuman}}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if not .AttachmentImage}}
|
||||||
|
<div class="float">
|
||||||
|
<div class="code" id="code"><div class="gutter" id="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
||||||
|
<script src="/static/paste.js" defer data-paste-id="{{.ID}}"></script>
|
||||||
|
<script src="/static/paste-lines.js" defer></script>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="page">
|
||||||
|
<div class="float">
|
||||||
|
<div class="settings-head">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<div class="settings-body">
|
||||||
|
<button class="iconbtn dark-toggle" id="settings-dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
<span>Dark mode</span>
|
||||||
|
</button>
|
||||||
|
<h3>Theme</h3>
|
||||||
|
<div class="theme-grid" id="theme-grid"></div>
|
||||||
|
<h3 class="mt18">Editor</h3>
|
||||||
|
<label class="toggle toggle-inline" for="wrap-setting"><input type="checkbox" id="wrap-setting"> Line wrap</label>
|
||||||
|
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/settings.js" defer></script>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{template "head" .}}
|
||||||
|
{{template "topbar" .}}
|
||||||
|
<div class="center">
|
||||||
|
<div class="float unlock-card">
|
||||||
|
<div class="inner">
|
||||||
|
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
|
||||||
|
<h1>This paste is locked</h1>
|
||||||
|
<p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p>
|
||||||
|
<form method="post" action="">
|
||||||
|
<div class="pw-field">
|
||||||
|
<input type="password" name="password" id="password" placeholder="Password" autocomplete="current-password" autofocus>
|
||||||
|
<button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
</div>
|
||||||
|
{{if .Wrong}}<p class="unlock-err">Wrong password. Try again.</p>{{end}}
|
||||||
|
<button class="btn" type="submit">Unlock</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/unlock.js" defer></script>
|
||||||
|
{{template "foot" .}}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// Version is the display version, set at build time via
|
||||||
|
// -ldflags "-X palette/internal/web.Version=v0.2.1" (#93). Falls back to
|
||||||
|
// "dev" for bare `go build` runs.
|
||||||
|
var Version = "dev"
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
|
||||||
|
// the admin page. Templates and static assets are embedded in this package.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
langpkg "palette/internal/lang"
|
||||||
|
"palette/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var tmplFS embed.FS
|
||||||
|
|
||||||
|
// DefaultDark is the server-configured default dark mode state for visitors
|
||||||
|
// without stored preferences (#127). Set via PALETTE_DEFAULT_DARK.
|
||||||
|
var DefaultDark = true
|
||||||
|
|
||||||
|
// SetDefaultDark applies the PALETTE_DEFAULT_DARK env var: unset = dark on,
|
||||||
|
// "false"/"0"/"off" = dark off.
|
||||||
|
func SetDefaultDark(v string) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "false", "0", "off":
|
||||||
|
DefaultDark = false
|
||||||
|
default:
|
||||||
|
DefaultDark = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePreset maps a theme pair id (or explicit variant id) plus dark state
|
||||||
|
// to a concrete preset id. Returns the defaults when pair is empty or unknown.
|
||||||
|
// Precedence handled by callers: URL ?theme= > stored pair+dark > defaults.
|
||||||
|
func ResolvePreset(pair string, dark bool) string {
|
||||||
|
variants := map[string][2]string{ // pair -> {light, dark}
|
||||||
|
"midnight": {"midnight-light", "midnight"},
|
||||||
|
"smooth": {"smooth", "smooth-dark"},
|
||||||
|
"pastel-lavender": {"pastel-lavender", "pastel-lavender-dark"},
|
||||||
|
"pastel-peach": {"pastel-peach", "pastel-peach-dark"},
|
||||||
|
"pastel-cloud": {"pastel-cloud", "pastel-cloud-dark"},
|
||||||
|
}
|
||||||
|
if v, ok := variants[pair]; ok {
|
||||||
|
if dark {
|
||||||
|
return v[1]
|
||||||
|
}
|
||||||
|
return v[0]
|
||||||
|
}
|
||||||
|
if dark {
|
||||||
|
return "midnight"
|
||||||
|
}
|
||||||
|
return "midnight-light"
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
type UI struct {
|
||||||
|
tmpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() (*UI, error) {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"humanSize": humanSize,
|
||||||
|
"version": func() string { return Version }, // #93: topbar version label
|
||||||
|
"defaultDark": func() bool { return DefaultDark }, // #127: server-configured default
|
||||||
|
}
|
||||||
|
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &UI{tmpl: t}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanSize(n int) string {
|
||||||
|
if n < 1024 {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
if n < 1024*1024 {
|
||||||
|
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UI) StaticHandler() http.Handler {
|
||||||
|
sub, _ := fs.Sub(staticFS, "static")
|
||||||
|
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
http.Error(w, "template error: "+err.Error(), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderNotFoundPage is the exported not-found renderer used by the api
|
||||||
|
// package (#173): the router's NotFound handler renders the main UI with a
|
||||||
|
// friendly "Paste ID not found" message, still with HTTP 404.
|
||||||
|
func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderNotFound(w, r, r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPage is the exported wrapper used by the api package (#4 can pages).
|
||||||
|
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
|
||||||
|
h.renderPage(w, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
|
||||||
|
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
||||||
|
h.writeRateLimited(w, retryAfterSecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
|
||||||
|
// Exported for the api package so can pages share paste cookie semantics (#4).
|
||||||
|
func UnlockToken(id string) string { return unlockToken(id) }
|
||||||
|
|
||||||
|
// AgoString formats a relative "N ago" string (exported for the api package, #4).
|
||||||
|
func AgoString(ts int64) string { return agoString(ts) }
|
||||||
|
|
||||||
|
// HumanSize formats a byte count as a human string (exported for the api package, #4).
|
||||||
|
func HumanSize(n int) string { return humanSize(n) }
|
||||||
|
|
||||||
|
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
|
||||||
|
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
|
||||||
|
|
||||||
|
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
||||||
|
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
||||||
|
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
||||||
|
// actually submitting the correct password for that paste.
|
||||||
|
var unlockSecret = resolveUnlockSecret()
|
||||||
|
|
||||||
|
func resolveUnlockSecret() []byte {
|
||||||
|
if v := os.Getenv("PALETTE_UNLOCK_SECRET"); v != "" {
|
||||||
|
return []byte(v)
|
||||||
|
}
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := cryptorand.Read(b); err != nil {
|
||||||
|
log.Fatal("cannot generate unlock secret: ", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlockToken(id string) string {
|
||||||
|
mac := hmac.New(sha256.New, unlockSecret)
|
||||||
|
mac.Write([]byte("unlock:" + id))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func agoString(ts int64) string {
|
||||||
|
s := time.Now().Unix() - ts
|
||||||
|
switch {
|
||||||
|
case s < 60:
|
||||||
|
return fmt.Sprintf("%ds ago", s)
|
||||||
|
case s < 3600:
|
||||||
|
return fmt.Sprintf("%dm ago", s/60)
|
||||||
|
case s < 86400:
|
||||||
|
return fmt.Sprintf("%dh ago", s/3600)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd ago", s/86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expiryString(expiresAt int64) string {
|
||||||
|
s := expiresAt - time.Now().Unix()
|
||||||
|
switch {
|
||||||
|
case s < 3600:
|
||||||
|
return fmt.Sprintf("%dm", s/60)
|
||||||
|
case s < 86400:
|
||||||
|
return fmt.Sprintf("%dh", s/3600)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd", s/86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers is the set of store callbacks the web pages need. The web package
|
||||||
|
// renders HTML; all queries go through the store.
|
||||||
|
type Handlers struct {
|
||||||
|
UI *UI
|
||||||
|
Store *store.Store
|
||||||
|
ViewerID func(r *http.Request) string
|
||||||
|
BurnWindowMin func() int
|
||||||
|
RateLimitOK func(id string, r *http.Request) bool // per-paste unlock limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) rateLimitUnlock(id string, r *http.Request) bool {
|
||||||
|
if h.RateLimitOK != nil {
|
||||||
|
return h.RateLimitOK(id, r)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
||||||
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSecs))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"error":"rate limit exceeded"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderNotFound serves the friendly not-found page (#173): the main UI
|
||||||
|
// chrome (topbar, centered card) with a "Paste ID not found" message in the
|
||||||
|
// result card, instead of a bare text 404. Still returns HTTP 404 so
|
||||||
|
// crawlers/validators see the correct status.
|
||||||
|
func (h *Handlers) renderNotFound(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
h.renderPageStatus(w, "notfound.html", http.StatusNotFound, map[string]any{"Page": "notfound", "ID": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNotFoundPage serves the friendly not-found page for unknown routes
|
||||||
|
// (#173): main UI chrome with a "Paste ID not found" message. Called from the
|
||||||
|
// chi NotFound handler in the api package.
|
||||||
|
func (h *Handlers) HandleNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderNotFound(w, r, r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPageStatus renders a template with an explicit HTTP status code.
|
||||||
|
func (h *Handlers) renderPageStatus(w http.ResponseWriter, name string, status int, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
http.Error(w, "template error: "+err.Error(), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachmentExt returns the lowercase file extension (without dot) of a
|
||||||
|
// filename, or "" when the name has none. Used by the Type display (#235).
|
||||||
|
func attachmentExt(filename string) string {
|
||||||
|
name := filename
|
||||||
|
if i := strings.LastIndexByte(name, '/'); i >= 0 {
|
||||||
|
name = name[i+1:]
|
||||||
|
}
|
||||||
|
i := strings.LastIndexByte(name, '.')
|
||||||
|
if i <= 0 || i == len(name)-1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(name[i+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
|
||||||
|
lines := strings.Count(row.Content, "\n") + 1
|
||||||
|
gutter := ""
|
||||||
|
for i := 1; i <= lines; i++ {
|
||||||
|
gutter += fmt.Sprintf("%d\n", i)
|
||||||
|
}
|
||||||
|
expIn := ""
|
||||||
|
if row.ExpiresAt.Valid {
|
||||||
|
expIn = expiryString(row.ExpiresAt.Int64)
|
||||||
|
}
|
||||||
|
lang := row.Language.String
|
||||||
|
if lang == "" {
|
||||||
|
lang = "text"
|
||||||
|
}
|
||||||
|
// #38: one optional file attachment per paste; nil when none.
|
||||||
|
attachment, err := h.Store.GetAttachmentForPaste(row.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "db error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #221: for attachment pastes the stored text content is empty (the file
|
||||||
|
// replaced it), so the summary size must come from the attachment blob,
|
||||||
|
// not len(row.Content), or the summary shows "0 B".
|
||||||
|
summarySize := len(row.Content)
|
||||||
|
if attachment != nil {
|
||||||
|
summarySize = int(attachment.Size)
|
||||||
|
}
|
||||||
|
// #235: column renamed to "Type". Text pastes keep the detected
|
||||||
|
// language; attachment pastes show the file extension instead.
|
||||||
|
attExt := ""
|
||||||
|
typeLabel := lang
|
||||||
|
if attachment != nil {
|
||||||
|
if ext := attachmentExt(attachment.Filename); ext != "" {
|
||||||
|
attExt = ext
|
||||||
|
typeLabel = ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary := fmt.Sprintf("%s · %s · %d views · %s", typeLabel, humanSize(summarySize), row.ViewCount, agoString(row.CreatedAt))
|
||||||
|
// #221: image attachments render the image, not a text/code box. Size
|
||||||
|
// comes from the attachment's actual file size, not the text content.
|
||||||
|
attImage := false
|
||||||
|
if attachment != nil {
|
||||||
|
switch attachment.Mime {
|
||||||
|
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
||||||
|
attImage = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeHuman := humanSize(len(row.Content))
|
||||||
|
lineCount := lines
|
||||||
|
if attachment != nil {
|
||||||
|
sizeHuman = attachment.SizeHuman
|
||||||
|
lineCount = 1
|
||||||
|
}
|
||||||
|
data := map[string]any{
|
||||||
|
"Page": "paste",
|
||||||
|
"ID": row.ID,
|
||||||
|
"Title": row.Title.String,
|
||||||
|
"Language": typeLabel,
|
||||||
|
"TypeLabel": typeLabel,
|
||||||
|
"HasAttachment": attachment != nil,
|
||||||
|
"AttachmentExt": attExt,
|
||||||
|
"StatsSummary": summary,
|
||||||
|
"SizeHuman": sizeHuman,
|
||||||
|
"HasPassword": row.PasswordHash.Valid,
|
||||||
|
"BurnAfterRead": row.BurnAfterRead,
|
||||||
|
"CustomSlug": row.CustomSlug.String,
|
||||||
|
"ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text
|
||||||
|
"ContentAttr": row.Content,
|
||||||
|
"Gutter": strings.TrimSuffix(gutter, "\n"),
|
||||||
|
"LineCount": lineCount,
|
||||||
|
"SizeBytes": len(row.Content),
|
||||||
|
"CreatedAgo": agoString(row.CreatedAt),
|
||||||
|
"CreatedAtUnix": row.CreatedAt,
|
||||||
|
"ViewCount": row.ViewCount,
|
||||||
|
"Visibility": row.Visibility,
|
||||||
|
"ExpiresAt": row.ExpiresAt.Valid,
|
||||||
|
"ExpiresIn": expIn,
|
||||||
|
"DeletionToken": deletionToken,
|
||||||
|
"ReadsLimit": row.ReadsLimit.Valid,
|
||||||
|
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
|
||||||
|
"ReadsTotal": int(row.ReadsLimit.Int64),
|
||||||
|
"JustCreated": justCreated,
|
||||||
|
"Attachment": attachment,
|
||||||
|
"AttachmentImage": attImage,
|
||||||
|
"Host": "this host",
|
||||||
|
}
|
||||||
|
h.renderPage(w, "paste.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePasteView renders the paste view; supports both ID and custom slug.
|
||||||
|
func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
row, err := h.Store.GetPaste(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "db error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
// #173: a missing paste ID gets the main UI with a friendly message,
|
||||||
|
// not a bare text 404 page.
|
||||||
|
h.renderNotFound(w, r, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
||||||
|
// #173: expired pastes render the same friendly not-found UI.
|
||||||
|
h.renderNotFound(w, r, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.PasswordHash.Valid {
|
||||||
|
// if a password was submitted via unlock form, verify and set cookie for this paste
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
if !h.rateLimitUnlock(row.ID, r) {
|
||||||
|
h.writeRateLimited(w, 60)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.ParseForm()
|
||||||
|
pw := r.FormValue("password")
|
||||||
|
if pw != "" && store.CheckPassword(row.PasswordHash.String, pw) {
|
||||||
|
// #34: the unlock cookie must be bound to this specific paste and
|
||||||
|
// unforgable. A static value ("1") let anyone bypass the password
|
||||||
|
// by setting pw_<id>=1 for any paste id. The token is an HMAC of
|
||||||
|
// the paste id under the server's random secret.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
|
||||||
|
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
// re-render without lock, or redirect if ?next= was given (#26)
|
||||||
|
if next := r.FormValue("next"); next != "" {
|
||||||
|
// only allow same-origin relative paths
|
||||||
|
if len(next) > 0 && next[0] == '/' && !strings.HasPrefix(next, "//") {
|
||||||
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.renderPaste(w, row, false, "", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// check cookie — must carry the valid per-paste unlock token (#34)
|
||||||
|
c, err := r.Cookie("pw_" + row.ID)
|
||||||
|
if err != nil || c.Value != unlockToken(row.ID) {
|
||||||
|
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
justCreated := r.URL.Query().Get("created") == "1"
|
||||||
|
// #143: the deletion token is no longer round-tripped through the URL
|
||||||
|
// (?token= leaks into access logs and history). The create flow sets a
|
||||||
|
// short-lived tok_<id> cookie; the paste view reads it once from there.
|
||||||
|
token := ""
|
||||||
|
if c, err := r.Cookie("tok_" + row.ID); err == nil {
|
||||||
|
token = c.Value
|
||||||
|
}
|
||||||
|
if justCreated && token != "" {
|
||||||
|
// one-time display of the deletion token via sessionStorage (#143)
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||||
|
}
|
||||||
|
// Count the view for real page renders, deduped per-viewer within the
|
||||||
|
// burn window (#95): a reload by the same vwr cookie doesn't inflate
|
||||||
|
// view_count. Raw views increment unconditionally in handleRaw (#49); the
|
||||||
|
// HTML path was missing its increment originally (#33). The just-created
|
||||||
|
// banner render does not count as a view.
|
||||||
|
if !justCreated {
|
||||||
|
h.Store.IncrementViews(row.ID, h.ViewerID(r), h.BurnWindowMin())
|
||||||
|
}
|
||||||
|
// #49: burn-after-N-reads budget (per-viewer dedupe window).
|
||||||
|
// Just-created first render does not count as a read for the creator.
|
||||||
|
if !justCreated {
|
||||||
|
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
|
||||||
|
if !admitted { // #58: lost the burn claim; #173: friendly not-found UI
|
||||||
|
h.renderNotFound(w, r, row.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderPaste(w, row, false, "", rem)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// only pass the token to the template right after creation
|
||||||
|
h.renderPaste(w, row, true, token, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNewPage serves /new.
|
||||||
|
func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHistoryPage serves /history.
|
||||||
|
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSettingsPage serves /settings.
|
||||||
|
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// #100: theme presets. Midnight is the default (no data-preset attribute),
|
||||||
|
// so its swatches are hardcoded here; the CSS defines the token values.
|
||||||
|
themes := []map[string]any{
|
||||||
|
{"Name": "midnight", "Label": "Midnight", "Swatches": []string{"#241B30", "#2D2340", "#3A2D52", "#C4A8F0", "#F2EDF8"}},
|
||||||
|
{"Name": "smooth", "Label": "Smooth", "Swatches": []string{"#F6F5FA", "#FFFFFF", "#DAD7E6", "#7A7796", "#2A2A36"}},
|
||||||
|
{"Name": "pastel-lavender", "Label": "Pastel Lavender", "Swatches": []string{"#e6e0f5", "#f1edfa", "#cbb8e7", "#806bb8", "#3E3059"}},
|
||||||
|
{"Name": "pastel-peach", "Label": "Pastel Peach", "Swatches": []string{"#ffe0d6", "#fff0ea", "#ffc4a8", "#f9826c", "#4F2318"}},
|
||||||
|
{"Name": "pastel-cloud", "Label": "Pastel Cloud", "Swatches": []string{"#fff0f6", "#fff7fb", "#ffc8dd", "#a2d2ff", "#4A3355"}},
|
||||||
|
}
|
||||||
|
h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMinePage serves /mine.
|
||||||
|
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAdminPage serves /admin.
|
||||||
|
func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderPage(w, "admin.html", map[string]any{"Page": "admin"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers builds a web.Handlers bound to this UI.
|
||||||
|
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
|
||||||
|
|
||||||
|
// #59: security headers for rendered HTML pages. Applied wherever the
|
||||||
|
// response is text/html (page templates and the inline can page); JSON API
|
||||||
|
// responses and /raw content pass through untouched.
|
||||||
|
// #139: script-src and style-src no longer allow 'unsafe-inline'. All
|
||||||
|
// previously-inline scripts moved to external files under static/ (page data
|
||||||
|
// reaches them via data-* attributes on the script tags), inline style
|
||||||
|
// attributes became CSS classes, and JS sets swatch colors via CSSOM. The
|
||||||
|
// img-src data: allowance stays: SVG data-URI backgrounds in app.css need it.
|
||||||
|
// default-src 'self' still blocks external content and object/frame embeds,
|
||||||
|
// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34
|
||||||
|
// pentest. Runs after the handler so the Content-Type is already set.
|
||||||
|
func SecurityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Set before the handler runs: once a handler writes (template render
|
||||||
|
// flushes), header mutations are silently dropped. Setting the headers
|
||||||
|
// unconditionally is safe: CSP/nosniff/referrer on JSON or /raw bodies
|
||||||
|
// is harmless and arguably desirable.
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Security-Policy",
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'")
|
||||||
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolvePreset(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
pair string
|
||||||
|
dark bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", false, "midnight-light"},
|
||||||
|
{"", true, "midnight"},
|
||||||
|
{"midnight", false, "midnight-light"},
|
||||||
|
{"midnight", true, "midnight"},
|
||||||
|
{"smooth", false, "smooth"},
|
||||||
|
{"smooth", true, "smooth-dark"},
|
||||||
|
{"pastel-lavender", true, "pastel-lavender-dark"},
|
||||||
|
{"pastel-peach", false, "pastel-peach"},
|
||||||
|
{"pastel-cloud", true, "pastel-cloud-dark"},
|
||||||
|
{"bogus", true, "midnight"},
|
||||||
|
{"bogus", false, "midnight-light"},
|
||||||
|
// explicit variant ids also resolve (URL ?theme= may pass them)
|
||||||
|
{"smooth-dark", true, "midnight"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ResolvePreset(c.pair, c.dark); got != c.want {
|
||||||
|
t.Errorf("ResolvePreset(%q, %v) = %q, want %q", c.pair, c.dark, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaultDark(t *testing.T) {
|
||||||
|
for _, off := range []string{"false", "0", "off", "OFF", " false "} {
|
||||||
|
DefaultDark = true
|
||||||
|
SetDefaultDark(off)
|
||||||
|
if DefaultDark {
|
||||||
|
t.Errorf("SetDefaultDark(%q): want dark off", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, on := range []string{"true", "1", "on", "anything"} {
|
||||||
|
DefaultDark = false
|
||||||
|
SetDefaultDark(on)
|
||||||
|
if !DefaultDark {
|
||||||
|
t.Errorf("SetDefaultDark(%q): want dark on", on)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DefaultDark = true
|
||||||
|
}
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed web/templates/* web/static/*
|
|
||||||
var webFS embed.FS
|
|
||||||
|
|
||||||
const (
|
|
||||||
softDeleteGraceDays = 7
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Addr string
|
|
||||||
DBPath string
|
|
||||||
MaxTextBytes int64
|
|
||||||
MaxItemBytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Paste struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CustomSlug *string `json:"custom_slug,omitempty"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ContentType string `json:"content_type"`
|
|
||||||
Language *string `json:"language,omitempty"`
|
|
||||||
Title *string `json:"title,omitempty"`
|
|
||||||
Password *string `json:"password,omitempty"`
|
|
||||||
ExpiresIn *string `json:"expires_in,omitempty"`
|
|
||||||
BurnAfterRead bool `json:"burn_after_read,omitempty"`
|
|
||||||
Visibility string `json:"visibility"`
|
|
||||||
CanID *string `json:"can_id,omitempty"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
|
||||||
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
|
||||||
ViewCount int `json:"view_count"`
|
|
||||||
DeletionToken string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PasteRow struct {
|
|
||||||
ID string
|
|
||||||
CustomSlug sql.NullString
|
|
||||||
Content string
|
|
||||||
ContentType string
|
|
||||||
Language sql.NullString
|
|
||||||
Title sql.NullString
|
|
||||||
PasswordHash sql.NullString
|
|
||||||
ExpiresAt sql.NullInt64
|
|
||||||
BurnAfterRead bool
|
|
||||||
Visibility string
|
|
||||||
CanID sql.NullString
|
|
||||||
CreatedAt int64
|
|
||||||
DeletedAt sql.NullInt64
|
|
||||||
ViewCount int
|
|
||||||
Size int
|
|
||||||
DeletionToken sql.NullString
|
|
||||||
}
|
|
||||||
|
|
||||||
type CanRow struct {
|
|
||||||
ID string
|
|
||||||
Title sql.NullString
|
|
||||||
Visibility string
|
|
||||||
PasswordHash sql.NullString
|
|
||||||
CreatedAt int64
|
|
||||||
DeletedAt sql.NullInt64
|
|
||||||
ExpiresAt sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func OpenStore(path string) (*Store, error) {
|
|
||||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s := &Store{db: db}
|
|
||||||
if err := s.migrate(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS pastes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
custom_slug TEXT UNIQUE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
content_type TEXT NOT NULL DEFAULT 'text/plain',
|
|
||||||
language TEXT,
|
|
||||||
title TEXT,
|
|
||||||
password_hash TEXT,
|
|
||||||
expires_at INTEGER,
|
|
||||||
burn_after_read INTEGER DEFAULT 0,
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'public',
|
|
||||||
can_id TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
view_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
deletion_token TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
|
|
||||||
CREATE TABLE IF NOT EXISTS paste_cans (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'public',
|
|
||||||
password_hash TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
deleted_at INTEGER,
|
|
||||||
expires_at INTEGER
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var slugAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
|
|
||||||
var httpClient = &http.Client{}
|
|
||||||
|
|
||||||
func genSlug(n int) string {
|
|
||||||
b := make([]byte, n)
|
|
||||||
_, _ = cryptorandRead(b)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = slugAlphabet[int(b[i])%len(slugAlphabet)]
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cryptorandRead wraps crypto/rand
|
|
||||||
func cryptorandRead(b []byte) (int, error) {
|
|
||||||
return cryptoRead(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|
||||||
id := genSlug(6)
|
|
||||||
now := time.Now().Unix()
|
|
||||||
|
|
||||||
var expiresAt *int64
|
|
||||||
if p.ExpiresIn != nil && *p.ExpiresIn != "" {
|
|
||||||
d, err := time.ParseDuration(*p.ExpiresIn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid expires_in: %w", err)
|
|
||||||
}
|
|
||||||
t := now + int64(d.Seconds())
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
var pwHash *string
|
|
||||||
if p.Password != nil && *p.Password != "" {
|
|
||||||
h, err := hashPassword(*p.Password)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pwHash = &h
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
|
||||||
slug := *p.CustomSlug
|
|
||||||
if err := ValidateCustomSlug(slug); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
taken, err := s.SlugTaken(slug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if taken {
|
|
||||||
return nil, errSlugTaken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visibility := p.Visibility
|
|
||||||
if visibility == "" {
|
|
||||||
visibility = "public"
|
|
||||||
}
|
|
||||||
if visibility != "public" && visibility != "unlisted" {
|
|
||||||
return nil, errors.New("visibility must be public or unlisted")
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := p.ContentType
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = "text/plain"
|
|
||||||
}
|
|
||||||
|
|
||||||
var slugVal *string
|
|
||||||
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
|
||||||
slugVal = p.CustomSlug
|
|
||||||
}
|
|
||||||
p.DeletionToken = genDeletionToken()
|
|
||||||
_, err := s.db.Exec(`INSERT INTO pastes
|
|
||||||
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
||||||
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.ID = id
|
|
||||||
p.CreatedAt = now
|
|
||||||
p.ExpiresAt = expiresAt
|
|
||||||
p.Visibility = visibility
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
|
|
||||||
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token
|
|
||||||
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
|
|
||||||
var r PasteRow
|
|
||||||
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &r, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
|
||||||
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes
|
|
||||||
WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
|
||||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []PasteRow
|
|
||||||
for rows.Next() {
|
|
||||||
var r PasteRow
|
|
||||||
var cs, lang, title sql.NullString
|
|
||||||
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
r.CustomSlug = cs
|
|
||||||
r.Language = lang
|
|
||||||
r.Title = title
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
var total int
|
|
||||||
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)`, time.Now().Unix()).Scan(&total)
|
|
||||||
return out, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SoftDelete(id string) error {
|
|
||||||
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) IncrementViews(id string) {
|
|
||||||
s.db.Exec(`UPDATE pastes SET view_count = view_count + 1 WHERE id = ?`, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SweepExpired soft-deletes expired pastes and hard-deletes soft-deleted pastes past grace.
|
|
||||||
func (s *Store) SweepExpired() {
|
|
||||||
now := time.Now().Unix()
|
|
||||||
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
|
|
||||||
grace := now - softDeleteGraceDays*86400
|
|
||||||
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) StartSweeper(every time.Duration) {
|
|
||||||
go func() {
|
|
||||||
t := time.NewTicker(every)
|
|
||||||
for range t.C {
|
|
||||||
s.SweepExpired()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashPassword(pw string) (string, error) {
|
|
||||||
// argon2id
|
|
||||||
return argon2idHash(pw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolToInt(b bool) int {
|
|
||||||
if b {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullStrPtr(ns sql.NullString) *string {
|
|
||||||
if ns.Valid {
|
|
||||||
return &ns.String
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiServer struct {
|
|
||||||
store *Store
|
|
||||||
cfg Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) routes() http.Handler {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Use(middleware.Recoverer)
|
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
|
||||||
|
|
||||||
// API
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
|
||||||
r.Post("/pastes", a.handleCreatePaste)
|
|
||||||
r.Get("/pastes/{id}", a.handleGetPaste)
|
|
||||||
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
|
||||||
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
|
||||||
r.Get("/public", a.handleListPublic)
|
|
||||||
r.Post("/pastes/can", a.handleCreateCan)
|
|
||||||
r.Get("/cans/{id}", a.handleGetCan)
|
|
||||||
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
// can page
|
|
||||||
r.Get("/can/{id}", a.handleCanPage)
|
|
||||||
|
|
||||||
// raw
|
|
||||||
r.Get("/raw/{id}", a.handleRaw)
|
|
||||||
|
|
||||||
// web pages
|
|
||||||
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
|
|
||||||
r.Get("/new", a.handleNewPage)
|
|
||||||
r.Get("/history", a.handleHistoryPage)
|
|
||||||
r.Handle("/static/*", staticHandler())
|
|
||||||
r.Get("/unlock/{id}", a.handlePasteView)
|
|
||||||
r.Post("/unlock/{id}", a.handlePasteView)
|
|
||||||
r.Get("/{id}", a.handlePasteView)
|
|
||||||
|
|
||||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeErr(w, 404, "not found")
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var p Paste
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
||||||
writeErr(w, 400, "invalid json body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(p.Content) == "" {
|
|
||||||
writeErr(w, 400, "content is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if int64(len(p.Content)) > a.cfg.MaxTextBytes {
|
|
||||||
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
created, err := a.store.CreatePaste(&p)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, 201, map[string]any{
|
|
||||||
"id": created.ID,
|
|
||||||
"deletion_token": created.DeletionToken,
|
|
||||||
"url": "/" + created.ID,
|
|
||||||
"raw_url": "/raw/" + created.ID,
|
|
||||||
"api_url": "/api/pastes/" + created.ID,
|
|
||||||
"expires_at": created.ExpiresAt,
|
|
||||||
"created_at": created.CreatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, 500, "db error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row == nil {
|
|
||||||
writeErr(w, 404, "paste not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
||||||
writeErr(w, 404, "paste expired")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.PasswordHash.Valid {
|
|
||||||
// require password via header or query
|
|
||||||
pw := r.Header.Get("X-Paste-Password")
|
|
||||||
if pw == "" {
|
|
||||||
pw = r.URL.Query().Get("password")
|
|
||||||
}
|
|
||||||
if pw == "" || !checkPassword(row.PasswordHash.String, pw) {
|
|
||||||
writeErr(w, 401, "password required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nullPtr := func(ns sql.NullString) *string {
|
|
||||||
if ns.Valid {
|
|
||||||
return &ns.String
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
a.store.maybeBurn(row)
|
|
||||||
writeJSON(w, 200, map[string]any{
|
|
||||||
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
|
||||||
"language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt,
|
|
||||||
"view_count": row.ViewCount, "visibility": row.Visibility,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil || row == nil {
|
|
||||||
writeErr(w, 404, "paste not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := a.store.SoftDelete(row.ID); err != nil {
|
|
||||||
writeErr(w, 500, "db error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 25
|
|
||||||
}
|
|
||||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
||||||
rows, total, err := a.store.ListPublic(limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, 500, "db error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]map[string]any, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title)
|
|
||||||
items = append(items, map[string]any{
|
|
||||||
"id": row.ID, "title": title, "language": lang,
|
|
||||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil || row == nil {
|
|
||||||
http.Error(w, "not found", 404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
||||||
http.Error(w, "paste expired", 404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.PasswordHash.Valid {
|
|
||||||
http.Error(w, "password required", 401)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", row.ContentType)
|
|
||||||
a.store.IncrementViews(row.ID)
|
|
||||||
w.Write([]byte(row.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
can, err := a.store.GetCan(id)
|
|
||||||
if err != nil || can == nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, _ := a.store.ListCanItems(can.ID)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
|
|
||||||
for _, it := range items {
|
|
||||||
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "</ul></body></html>")
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullStrOr(ns sql.NullString, def string) string {
|
|
||||||
if ns.Valid {
|
|
||||||
return ns.String
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleHome(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
|
||||||
w.Write([]byte("palette pastebin api\nPOST /api/pastes {\"content\": \"...\", \"language\": \"go\", \"expires_in\": \"168h\", \"password\": \"...\", \"visibility\": \"public\"}\nGET /api/pastes/{id}\nGET /api/public?limit=25&offset=0\nGET /raw/{id}\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handlePastePage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
// if it looks like an asset request, 404
|
|
||||||
if strings.Contains(id, ".") {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil || row == nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.store.IncrementViews(row.ID)
|
|
||||||
// render basic view; full templates come later with frontend work
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, "<!doctype html><html><head><title>%s — palette</title></head><body><pre>%s</pre></body></html>",
|
|
||||||
row.ID, templateEsc(row.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func templateEsc(s string) string {
|
|
||||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
|
||||||
return r.Replace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg := Config{
|
|
||||||
Addr: envOr("PALETTE_ADDR", ":8080"),
|
|
||||||
DBPath: envOr("PALETTE_DB", "palette.db"),
|
|
||||||
MaxTextBytes: int64(envIntOr("PALETTE_MAX_TEXT", 5*1024*1024)),
|
|
||||||
MaxItemBytes: int64(envIntOr("PALETTE_MAX_ITEM", 25*1024*1024)),
|
|
||||||
}
|
|
||||||
store, err := OpenStore(cfg.DBPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
store.StartSweeper(time.Minute)
|
|
||||||
|
|
||||||
ui, err := NewWebUI()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
webUIInstance = ui
|
|
||||||
srv := &apiServer{store: store, cfg: cfg}
|
|
||||||
log.Printf("palette listening on %s", cfg.Addr)
|
|
||||||
log.Fatal(http.ListenAndServe(cfg.Addr, srv.routes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOr(k, d string) string {
|
|
||||||
if v := os.Getenv(k); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func envIntOr(k string, d int) int {
|
|
||||||
if v := os.Getenv(k); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,183 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed web/templates/*.html
|
|
||||||
var tmplFS embed.FS
|
|
||||||
|
|
||||||
//go:embed web/static
|
|
||||||
var staticFS embed.FS
|
|
||||||
|
|
||||||
type webUI struct {
|
|
||||||
tmpl *template.Template
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWebUI() (*webUI, error) {
|
|
||||||
funcs := template.FuncMap{
|
|
||||||
"humanSize": humanSize,
|
|
||||||
}
|
|
||||||
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "web/templates/*.html")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &webUI{tmpl: t}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func humanSize(n int) string {
|
|
||||||
if n < 1024 {
|
|
||||||
return fmt.Sprintf("%d B", n)
|
|
||||||
}
|
|
||||||
if n < 1024*1024 {
|
|
||||||
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
|
||||||
}
|
|
||||||
|
|
||||||
func staticHandler() http.Handler {
|
|
||||||
sub, _ := fs.Sub(staticFS, "web/static")
|
|
||||||
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderPage(w http.ResponseWriter, name string, data any) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := webUIInstance.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
||||||
http.Error(w, "template error: "+err.Error(), 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var webUIInstance *webUI
|
|
||||||
|
|
||||||
func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
renderPage(w, "new.html", map[string]any{"Page": "new"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) handleHistoryPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
renderPage(w, "history.html", map[string]any{"Page": "history"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func agoString(ts int64) string {
|
|
||||||
s := time.Now().Unix() - ts
|
|
||||||
switch {
|
|
||||||
case s < 60:
|
|
||||||
return fmt.Sprintf("%ds ago", s)
|
|
||||||
case s < 3600:
|
|
||||||
return fmt.Sprintf("%dm ago", s/60)
|
|
||||||
case s < 86400:
|
|
||||||
return fmt.Sprintf("%dh ago", s/3600)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%dd ago", s/86400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func expiryString(expiresAt int64) string {
|
|
||||||
s := expiresAt - time.Now().Unix()
|
|
||||||
switch {
|
|
||||||
case s < 3600:
|
|
||||||
return fmt.Sprintf("%dm", s/60)
|
|
||||||
case s < 86400:
|
|
||||||
return fmt.Sprintf("%dh", s/3600)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%dd", s/86400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string) {
|
|
||||||
lines := strings.Count(row.Content, "\n") + 1
|
|
||||||
gutter := ""
|
|
||||||
for i := 1; i <= lines; i++ {
|
|
||||||
gutter += fmt.Sprintf("%d\n", i)
|
|
||||||
}
|
|
||||||
expIn := ""
|
|
||||||
if row.ExpiresAt.Valid {
|
|
||||||
expIn = expiryString(row.ExpiresAt.Int64)
|
|
||||||
}
|
|
||||||
data := map[string]any{
|
|
||||||
"Page": "paste",
|
|
||||||
"ID": row.ID,
|
|
||||||
"Title": row.Title.String,
|
|
||||||
"Language": row.Language.String,
|
|
||||||
"ContentHTML": template.HTMLEscapeString(row.Content),
|
|
||||||
"ContentAttr": row.Content,
|
|
||||||
"Gutter": strings.TrimSuffix(gutter, "\n"),
|
|
||||||
"LineCount": lines,
|
|
||||||
"SizeBytes": len(row.Content),
|
|
||||||
"CreatedAgo": agoString(row.CreatedAt),
|
|
||||||
"ViewCount": row.ViewCount,
|
|
||||||
"Visibility": row.Visibility,
|
|
||||||
"ExpiresAt": row.ExpiresAt.Valid,
|
|
||||||
"ExpiresIn": expIn,
|
|
||||||
"DeletionToken": deletionToken,
|
|
||||||
"JustCreated": justCreated,
|
|
||||||
"Host": "this host",
|
|
||||||
}
|
|
||||||
renderPage(w, "paste.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePastePage renders the paste view; supports both ID and custom slug.
|
|
||||||
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
row, err := a.store.GetPaste(id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "db error", 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row == nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
||||||
http.Error(w, "paste expired", 404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if row.PasswordHash.Valid {
|
|
||||||
// if a password was submitted via unlock form, verify and set cookie for this paste
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
r.ParseForm()
|
|
||||||
pw := r.FormValue("password")
|
|
||||||
if pw != "" && checkPassword(row.PasswordHash.String, pw) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "pw_" + row.ID, Value: "1", Path: "/",
|
|
||||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
// re-render without lock
|
|
||||||
a.renderPaste(w, row, false, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// check cookie
|
|
||||||
c, err := r.Cookie("pw_" + row.ID)
|
|
||||||
if err != nil || c.Value != "1" {
|
|
||||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.store.IncrementViews(row.ID)
|
|
||||||
justCreated := r.URL.Query().Get("created") == "1"
|
|
||||||
token := r.URL.Query().Get("token")
|
|
||||||
if justCreated && token != "" {
|
|
||||||
// one-time display of the deletion token via the created banner
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
|
||||||
}
|
|
||||||
// only pass the token to the template right after creation
|
|
||||||
if justCreated {
|
|
||||||
a.renderPaste(w, row, true, token)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.renderPaste(w, row, false, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = strconv.Itoa
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
/* Palette UI tokens (mirrors sketches/themes/tokens.css, midnight approved preset) */
|
|
||||||
:root {
|
|
||||||
--bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52;
|
|
||||||
--muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8;
|
|
||||||
--accent: #C4A8F0; --border: #42355C;
|
|
||||||
--radius: 14px;
|
|
||||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace;
|
|
||||||
}
|
|
||||||
[data-preset="smooth"] {
|
|
||||||
--bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6;
|
|
||||||
--muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36;
|
|
||||||
--accent: #7A7796; --border: #DAD7E6;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
line-height: 1.45;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
min-height: 100vh;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.topbar {
|
|
||||||
display: flex; align-items: center; gap: 20px;
|
|
||||||
padding: 0 20px; height: 52px;
|
|
||||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.logo { font-weight: 700; font-size: 16px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); }
|
|
||||||
.logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; }
|
|
||||||
.topbar nav { display: flex; gap: 4px; }
|
|
||||||
.topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: 8px; font-size: 13.5px; }
|
|
||||||
.topbar nav a:hover { background: var(--surface-2); color: var(--fg); }
|
|
||||||
.topbar nav a.on { background: var(--accent); color: var(--bg); }
|
|
||||||
.topbar .spacer { flex: 1; }
|
|
||||||
.kbd { font-family: var(--font-mono); font-size: 11px; border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; color: var(--muted-fg); }
|
|
||||||
|
|
||||||
.float {
|
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
box-shadow: 0 2px 6px rgba(20,14,32,.25), 0 12px 32px rgba(20,14,32,.3);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* new paste page */
|
|
||||||
.deck {
|
|
||||||
display: grid; grid-template-columns: 1fr 300px; gap: 16px;
|
|
||||||
padding: 16px 20px; height: calc(100vh - 52px);
|
|
||||||
max-width: 1400px; margin: 0 auto;
|
|
||||||
}
|
|
||||||
.pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; }
|
|
||||||
.side-section { padding: 14px 16px; flex-shrink: 0; }
|
|
||||||
.side-section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
|
|
||||||
.pane-l { display: flex; flex-direction: column; }
|
|
||||||
.editor-head {
|
|
||||||
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.editor-head input {
|
|
||||||
border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13.5px; flex: 1;
|
|
||||||
}
|
|
||||||
.editor-head select {
|
|
||||||
border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg);
|
|
||||||
border-radius: 7px; padding: 4px 10px; font: inherit; font-size: 12.5px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.editor-wrap { flex: 1; display: flex; min-height: 0; }
|
|
||||||
.gutter {
|
|
||||||
padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono);
|
|
||||||
font-size: 12.5px; line-height: 1.7; user-select: none; white-space: pre; overflow: hidden;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.editor {
|
|
||||||
flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7;
|
|
||||||
white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg);
|
|
||||||
resize: none; width: 100%;
|
|
||||||
}
|
|
||||||
.editor::placeholder { color: var(--muted); }
|
|
||||||
.actionbar {
|
|
||||||
display: flex; align-items: center; gap: 14px; padding: 12px 16px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: var(--accent); color: var(--bg); border: none; cursor: pointer;
|
|
||||||
padding: 8px 18px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600;
|
|
||||||
}
|
|
||||||
.btn:hover { filter: brightness(1.08); }
|
|
||||||
.hint { font-size: 12px; color: var(--muted-fg); }
|
|
||||||
.hint b { color: var(--fg); font-weight: 550; }
|
|
||||||
.seg { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 7px; cursor: pointer; font-size: 13px; }
|
|
||||||
.seg label:hover { background: var(--surface-2); }
|
|
||||||
.seg input { accent-color: var(--accent); }
|
|
||||||
.toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; padding: 5px 8px; border-radius: 7px; }
|
|
||||||
.toggle:hover { background: var(--surface-2); }
|
|
||||||
.toggle input { accent-color: var(--accent); }
|
|
||||||
.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; padding: 4px 0; }
|
|
||||||
.row input[type="text"] {
|
|
||||||
border: 1px solid var(--border); border-radius: 7px; padding: 5px 8px; background: var(--bg);
|
|
||||||
color: var(--fg); font: inherit; font-size: 12.5px; width: 130px;
|
|
||||||
}
|
|
||||||
.created-banner {
|
|
||||||
display: none; padding: 10px 16px; font-size: 13px; background: var(--surface-2);
|
|
||||||
border-bottom: 1px solid var(--border); word-break: break-all;
|
|
||||||
}
|
|
||||||
.created-banner a { color: var(--accent); }
|
|
||||||
|
|
||||||
/* paste view */
|
|
||||||
.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
|
|
||||||
.meta-bar h1 { font-size: 17px; font-weight: 600; }
|
|
||||||
.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: 7px; }
|
|
||||||
.tag { font-size: 11.5px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; }
|
|
||||||
.meta-bar .spacer { flex: 1; }
|
|
||||||
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 8px; padding: 5px 12px; font: inherit; font-size: 12.5px; cursor: pointer; text-decoration: none; }
|
|
||||||
.iconbtn:hover { color: var(--fg); border-color: var(--muted); }
|
|
||||||
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
|
|
||||||
.code-head {
|
|
||||||
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
|
|
||||||
border-bottom: 1px solid var(--border); font-size: 12.5px; color: var(--muted-fg);
|
|
||||||
}
|
|
||||||
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
|
||||||
.code {
|
|
||||||
font-family: var(--font-mono); font-size: 13px; line-height: 1.7;
|
|
||||||
padding: 14px 0; display: flex; overflow-x: auto;
|
|
||||||
}
|
|
||||||
.code .gutter { flex-shrink: 0; }
|
|
||||||
.codebody { padding: 0 18px; white-space: pre; }
|
|
||||||
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 12px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* history */
|
|
||||||
.page { max-width: 860px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.head-row { display: flex; align-items: baseline; gap: 14px; }
|
|
||||||
.head-row h1 { font-size: 20px; font-weight: 600; }
|
|
||||||
.search {
|
|
||||||
display: flex; align-items: center; gap: 8px; background: var(--surface);
|
|
||||||
border: 1px solid var(--border); border-radius: 10px; padding: 8px 14px; width: 260px;
|
|
||||||
}
|
|
||||||
.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13px; width: 100%; }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
|
||||||
th:nth-child(1), td:nth-child(1) { width: 130px; }
|
|
||||||
th:nth-child(2), td:nth-child(2) { width: auto; }
|
|
||||||
th:nth-child(3), td:nth-child(3) { width: 80px; }
|
|
||||||
th:nth-child(4), td:nth-child(4) { width: 80px; }
|
|
||||||
th:nth-child(5), td:nth-child(5) { width: 64px; }
|
|
||||||
th:nth-child(6), td:nth-child(6) { width: 90px; }
|
|
||||||
th {
|
|
||||||
text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
|
||||||
color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600;
|
|
||||||
}
|
|
||||||
td { padding: 10px 16px; border-bottom: 1px solid var(--border); }
|
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
tr.row:hover td { background: var(--surface-2); }
|
|
||||||
td a.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); text-decoration: none; }
|
|
||||||
td a.slug:hover { color: var(--accent); }
|
|
||||||
.badge { font-size: 11px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: 999px; padding: 1px 8px; }
|
|
||||||
.badge.lock { color: var(--accent); border-color: var(--accent); }
|
|
||||||
.dim { color: var(--muted-fg); white-space: nowrap; }
|
|
||||||
.pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 12.5px; color: var(--muted-fg); }
|
|
||||||
.pager .pg { display: flex; gap: 6px; }
|
|
||||||
.pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 7px; padding: 4px 11px; font: inherit; font-size: 12.5px; cursor: pointer; }
|
|
||||||
.pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); }
|
|
||||||
.pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
|
||||||
.pager button:disabled { opacity: .4; cursor: default; }
|
|
||||||
.empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 13px; }
|
|
||||||
|
|
||||||
/* unlock */
|
|
||||||
.center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); }
|
|
||||||
.center .float { width: 400px; max-width: 100%; }
|
|
||||||
.inner { padding: 28px; text-align: center; }
|
|
||||||
.lockring {
|
|
||||||
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
|
|
||||||
background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 24px;
|
|
||||||
}
|
|
||||||
.inner h1 { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
|
|
||||||
.inner .sub { font-size: 13px; color: var(--muted-fg); margin-bottom: 20px; }
|
|
||||||
.pwinput {
|
|
||||||
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 9px;
|
|
||||||
background: var(--bg); color: var(--fg); font: inherit; font-size: 13.5px; outline: none; text-align: center;
|
|
||||||
letter-spacing: .12em;
|
|
||||||
}
|
|
||||||
.pwinput:focus { border-color: var(--accent); }
|
|
||||||
.center .btn { width: 100%; margin-top: 12px; }
|
|
||||||
.err { display: none; margin-top: 12px; font-size: 12.5px; color: #ff8fa3; }
|
|
||||||
.center .foot { font-size: 12px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{define "foot"}}{{end}}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
{{template "head" .}}
|
|
||||||
{{template "topbar" .}}
|
|
||||||
<div class="page">
|
|
||||||
<div class="head-row">
|
|
||||||
<h1>Public pastes</h1>
|
|
||||||
<span class="count" id="count"></span>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
<div class="search"><input id="filter" placeholder="filter…"></div>
|
|
||||||
</div>
|
|
||||||
<div class="float">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Paste</th><th>Description</th><th>Language</th><th>Size</th><th>Views</th><th>Created</th></tr></thead>
|
|
||||||
<tbody id="rows"></tbody>
|
|
||||||
</table>
|
|
||||||
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
|
|
||||||
<div class="pager">
|
|
||||||
<span id="showing"></span>
|
|
||||||
<div class="pg" id="pg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const PER = 25;
|
|
||||||
let page = 1, total = 0;
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
|
|
||||||
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
|
||||||
function fmtSize(n) { if (n == null) return '—'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; }
|
|
||||||
function ago(ts) {
|
|
||||||
const s = Math.floor(Date.now()/1000) - ts;
|
|
||||||
if (s < 60) return s + 's ago';
|
|
||||||
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
|
||||||
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
|
||||||
return Math.floor(s/86400) + 'd ago';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const off = (page - 1) * PER;
|
|
||||||
const res = await fetch('/api/public?limit=' + PER + '&offset=' + off);
|
|
||||||
const data = await res.json();
|
|
||||||
total = data.total;
|
|
||||||
|
|
||||||
$('count').textContent = total.toLocaleString() + ' total';
|
|
||||||
const rows = $('rows');
|
|
||||||
if (data.items.length === 0) {
|
|
||||||
rows.innerHTML = '';
|
|
||||||
$('empty').style.display = 'block';
|
|
||||||
} else {
|
|
||||||
$('empty').style.display = 'none';
|
|
||||||
rows.innerHTML = data.items.map(it =>
|
|
||||||
`<tr class="row"><td><a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a></td>` +
|
|
||||||
`<td class="title-cell">${it.title ? esc(it.title) : '<span class=dim>—</span>'}</td>` +
|
|
||||||
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
|
|
||||||
`<td class="dim">${fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim">${ago(it.created_at)}</td></tr>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = Math.max(1, Math.ceil(total / PER));
|
|
||||||
$('showing').textContent = total === 0 ? 'Nothing here yet' :
|
|
||||||
`Showing ${off+1}–${Math.min(off+PER, total)} of ${total.toLocaleString()} · page ${page} of ${pages}`;
|
|
||||||
|
|
||||||
const btns = [];
|
|
||||||
const add = (label, target, opts={}) => btns.push(`<button ${opts.on?'class="on"':''} ${opts.dis?'disabled':''} data-p="${target}">${label}</button>`);
|
|
||||||
add('‹', page-1, {dis: page===1});
|
|
||||||
const win = new Set([1, 2, page-1, page, page+1, pages]);
|
|
||||||
let last = 0;
|
|
||||||
for (let i = 1; i <= pages; i++) {
|
|
||||||
if (win.has(i)) {
|
|
||||||
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
|
|
||||||
add(String(i), i, {on: i===page});
|
|
||||||
last = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add('›', page+1, {dis: page===pages});
|
|
||||||
$('pg').innerHTML = btns.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
$('pg').addEventListener('click', e => {
|
|
||||||
const b = e.target.closest('button[data-p]');
|
|
||||||
if (!b || b.disabled) return;
|
|
||||||
page = parseInt(b.dataset.p);
|
|
||||||
load();
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
});
|
|
||||||
$('filter').addEventListener('input', () => { page = 1; load(); });
|
|
||||||
load();
|
|
||||||
setInterval(load, 30000); // auto-refresh history every 30s
|
|
||||||
</script>
|
|
||||||
{{template "foot" .}}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{{define "head"}}
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "topbar"}}
|
|
||||||
<div class="topbar">
|
|
||||||
<a class="logo" href="/history">Palette <em>/ beta</em></a>
|
|
||||||
<nav>
|
|
||||||
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>new</a>
|
|
||||||
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>history</a>
|
|
||||||
<a href="https://git.archfox.org/poslop/palette">git</a>
|
|
||||||
</nav>
|
|
||||||
<div class="spacer"></div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
{{template "head" .}}
|
|
||||||
{{template "topbar" .}}
|
|
||||||
<div class="deck">
|
|
||||||
<div class="float pane-l">
|
|
||||||
<div class="editor-head">
|
|
||||||
<input id="title" placeholder="title (optional)">
|
|
||||||
<select id="language">
|
|
||||||
<option value="">auto</option>
|
|
||||||
<option>go</option><option>python</option><option>javascript</option>
|
|
||||||
<option>rust</option><option>c</option><option>cpp</option><option>java</option>
|
|
||||||
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
|
|
||||||
<option>markdown</option><option>text</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="editor-wrap">
|
|
||||||
<div class="gutter" id="gutter">1</div>
|
|
||||||
<textarea class="editor" id="content" placeholder="paste your code, text, or notes here…" spellcheck="false"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="created-banner" id="created"></div>
|
|
||||||
<div class="actionbar">
|
|
||||||
<span class="hint">Ctrl+Enter to create</span>
|
|
||||||
<div class="spacer" style="flex:1"></div>
|
|
||||||
<button class="btn" id="create">Create ⇧</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pane-r">
|
|
||||||
<div class="float side-section">
|
|
||||||
<h3>Expiry</h3>
|
|
||||||
<div class="seg">
|
|
||||||
<label><input type="radio" name="exp" value=""> Never</label>
|
|
||||||
<label><input type="radio" name="exp" value="1h"> 1 hour</label>
|
|
||||||
<label><input type="radio" name="exp" value="24h"> 1 day</label>
|
|
||||||
<label><input type="radio" name="exp" value="168h" checked> 1 week</label>
|
|
||||||
<label><input type="radio" name="exp" value="720h"> 30 days</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="float side-section">
|
|
||||||
<h3>Protection</h3>
|
|
||||||
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
|
|
||||||
<input type="password" id="password" class="pwinput" placeholder="password" style="display:none; margin: 6px 8px 0; width: auto;">
|
|
||||||
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
|
|
||||||
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
|
|
||||||
</div>
|
|
||||||
<div class="float side-section">
|
|
||||||
<h3>Custom URL</h3>
|
|
||||||
<div class="row"><span>/</span><input type="text" id="custom" placeholder="my-snippet"></div>
|
|
||||||
</div>
|
|
||||||
<div class="float side-section">
|
|
||||||
<h3>Result</h3>
|
|
||||||
<div class="hint" id="result" style="word-break:break-all">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
const content = $('content'), gutter = $('gutter');
|
|
||||||
|
|
||||||
function updateGutter() {
|
|
||||||
const lines = content.value.split('\n').length;
|
|
||||||
let s = '';
|
|
||||||
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
|
|
||||||
gutter.textContent = s;
|
|
||||||
}
|
|
||||||
content.addEventListener('input', updateGutter);
|
|
||||||
updateGutter();
|
|
||||||
|
|
||||||
$('haspw').addEventListener('change', e => { $('password').style.display = e.target.checked ? 'block' : 'none'; });
|
|
||||||
|
|
||||||
async function create() {
|
|
||||||
const body = {
|
|
||||||
content: content.value,
|
|
||||||
title: $('title').value || null,
|
|
||||||
language: $('language').value || null,
|
|
||||||
custom_slug: $('custom').value || null,
|
|
||||||
burn_after_read: $('burn').checked,
|
|
||||||
};
|
|
||||||
if ($('haspw').checked) body.password = $('password').value;
|
|
||||||
const exp = document.querySelector('input[name="exp"]:checked').value;
|
|
||||||
if (exp) body.expires_in = exp;
|
|
||||||
|
|
||||||
const res = await fetch('/api/pastes', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) {
|
|
||||||
$('result').textContent = 'Error: ' + (data.error || res.status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = location.origin + '/' + (data.custom_slug || data.id);
|
|
||||||
$('result').innerHTML = '<a href="' + url + '">' + url + '</a>';
|
|
||||||
$('result').dataset.token = data.deletion_token || '';
|
|
||||||
try { navigator.clipboard.writeText(url); } catch(e) {}
|
|
||||||
// show the paste
|
|
||||||
location.href = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
|
|
||||||
}
|
|
||||||
$('create').addEventListener('click', create);
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{template "foot" .}}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{{template "head" .}}
|
|
||||||
{{template "topbar" .}}
|
|
||||||
<div class="page">
|
|
||||||
<div class="float">
|
|
||||||
<div class="meta-bar">
|
|
||||||
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
|
||||||
<span class="slug">/{{.ID}}</span>
|
|
||||||
{{if .Language}}<span class="tag">{{.Language}}</span>{{end}}
|
|
||||||
{{if .ExpiresAt}}<span class="tag">expires in {{.ExpiresIn}}</span>{{end}}
|
|
||||||
<div class="spacer"></div>
|
|
||||||
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
|
||||||
<a class="iconbtn" href="#" onclick="copyContent(); return false;">copy</a>
|
|
||||||
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{if .JustCreated}}
|
|
||||||
<div class="float">
|
|
||||||
<div class="created-banner" style="display:block">
|
|
||||||
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
|
|
||||||
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
<div class="float">
|
|
||||||
<div class="code-head"><span class="dot"></span> {{.LineCount}} lines · {{.SizeBytes}} bytes</div>
|
|
||||||
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
|
|
||||||
<div class="footnote">
|
|
||||||
<span>Created {{.CreatedAgo}}</span>
|
|
||||||
<span>{{.ViewCount}} views</span>
|
|
||||||
<span>{{.Visibility}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
|
||||||
<script>
|
|
||||||
function copyContent() {
|
|
||||||
navigator.clipboard.writeText(document.getElementById('raw-content').value);
|
|
||||||
}
|
|
||||||
function redeem(token) {
|
|
||||||
if (!confirm('Hard delete this paste immediately?')) return;
|
|
||||||
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
|
|
||||||
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{{template "foot" .}}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user