Compare commits

...

5 Commits

Author SHA1 Message Date
Lukas Schulte Pelkum 6260f20fc4
implement info endpoint 2023-06-08 19:55:43 +02:00
Lukas Schulte Pelkum dc16506932
integrate the web server into the startup logic 2023-06-08 19:44:32 +02:00
Lukas Schulte Pelkum a24be8b2ff
undo accidental string replacements 2023-06-08 19:27:27 +02:00
Lukas Schulte Pelkum 4ce806945d
implement raw paste handler 2023-06-08 19:24:32 +02:00
Lukas Schulte Pelkum a53bd39dbd
implement frontend routing 2023-06-08 19:17:34 +02:00
23 changed files with 179 additions and 32 deletions

5
.gitignore vendored
View File

@ -1,4 +1,3 @@
# Created by https://www.toptal.com/developers/gitignore/api/jetbrains+all,go
# Edit at https://www.toptal.com/developers/gitignore?templates=jetbrains+all,go
@ -114,6 +113,4 @@ modules.xml
# End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,go
web/*.gz
data/
.env
.env

View File

@ -22,6 +22,6 @@ RUN go build \
FROM gcr.io/distroless/base:latest
WORKDIR /root
COPY --from=build /app/pasty .
COPY web ./web/
COPY internal/web/web ./web/
EXPOSE 8080
CMD ["./pasty"]

View File

@ -2,12 +2,15 @@ package main
import (
"context"
"errors"
"github.com/lus/pasty/internal/config"
"github.com/lus/pasty/internal/meta"
"github.com/lus/pasty/internal/storage"
"github.com/lus/pasty/internal/storage/postgres"
"github.com/lus/pasty/internal/web"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"net/http"
"os"
"os/signal"
"strings"
@ -65,6 +68,36 @@ func main() {
}
}()
// Start the web server
log.Info().Str("address", cfg.WebAddress).Msg("Starting the web server...")
var adminTokens []string
if cfg.ModificationTokenMaster != "" {
adminTokens = []string{cfg.ModificationTokenMaster}
}
webServer := &web.Server{
Address: cfg.WebAddress,
Storage: driver,
HastebinSupport: cfg.HastebinSupport,
PasteIDLength: cfg.IDLength,
PasteIDCharset: cfg.IDCharacters,
PasteLengthCap: cfg.LengthCap,
ModificationTokensEnabled: cfg.ModificationTokens,
ModificationTokenLength: cfg.ModificationTokenLength,
ModificationTokenCharset: cfg.ModificationTokenCharacters,
AdminTokens: adminTokens,
}
go func() {
if err := webServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err).Msg("Could not start the web server.")
}
}()
defer func() {
log.Info().Msg("Shutting down the web server...")
if err := webServer.Shutdown(context.Background()); err != nil {
log.Err(err).Msg("Could not shut down the web server.")
}
}()
// Wait for an interrupt signal
log.Info().Msg("The application has been started. Use Ctrl+C to shut it down.")
shutdownChan := make(chan os.Signal, 1)

View File

@ -6,6 +6,7 @@ const devEnvironmentName = "dev"
var (
Environment = devEnvironmentName
Version = "dev"
)
func IsProdEnvironment() bool {

View File

@ -54,7 +54,6 @@ func (driver *Driver) Initialize(ctx context.Context) error {
pool.Close()
return err
}
log.Info().Msg("Successfully performed PostgreSQL database migrations.")
driver.connPool = pool
driver.pastes = &pasteRepository{

View File

@ -1,4 +1,4 @@
const API_BASE_URL = location.protocol + "//" + location.host + "/web/v2";
const API_BASE_URL = location.protocol + "//" + location.host + "/api/v2";
export async function getAPIInformation() {
return fetch(API_BASE_URL + "/info");

View File

@ -64,20 +64,20 @@ export async function initialize() {
}
if (location.pathname !== "/") {
// Extract the pastes data (ID and language)
// Extract the paste data (ID and language)
const split = location.pathname.replace("/", "").split(".");
const pasteID = split[0];
const language = split[1];
// Try to retrieve the pastes data from the API
// Try to retrieve the paste data from the API
const response = await API.getPaste(pasteID);
if (!response.ok) {
Notifications.error("Could not load pastes: <b>" + await response.text() + "</b>");
Notifications.error("Could not load paste: <b>" + await response.text() + "</b>");
setTimeout(() => location.replace(location.protocol + "//" + location.host), 3000);
return;
}
// Set the persistent pastes data
// Set the persistent paste data
PASTE_ID = pasteID;
LANGUAGE = language;
@ -95,7 +95,7 @@ export async function initialize() {
ENCRYPTION_IV = json.metadata.pf_encryption.iv;
} catch (error) {
console.log(error);
Notifications.error("Could not decrypt pastes; make sure the decryption key is correct.");
Notifications.error("Could not decrypt paste; make sure the decryption key is correct.");
setTimeout(() => location.replace(location.protocol + "//" + location.host), 3000);
return;
}
@ -104,7 +104,7 @@ export async function initialize() {
// Fill the code block with the just received data
updateCode();
} else {
// Give the user the opportunity to pastes his code
// Give the user the opportunity to paste his code
INPUT_ELEMENT.classList.remove("hidden");
INPUT_ELEMENT.focus();
LIFETIME_CONTAINER_ELEMENT.classList.remove("hidden");
@ -138,7 +138,7 @@ async function loadAPIInformation() {
// Display the API version
document.getElementById("version").innerText = API_INFORMATION.version;
// Display the pastes lifetime
// Display the paste lifetime
document.getElementById("lifetime").innerText = Duration.format(API_INFORMATION.pasteLifetime);
}
@ -215,7 +215,7 @@ function toggleEditMode() {
function setupKeybinds() {
window.addEventListener("keydown", (event) => {
// All keybinds in the default button set include the CTRL key
if ((EDIT_MODE && !event.ctrlKey && event.code !== "Escape") || (!EDIT_MODE && !event.ctrlKey)) {
if ((EDIT_MODE && !event.ctrlKey && !event.metaKey && event.code !== "Escape") || (!EDIT_MODE && !event.ctrlKey && !event.metaKey)) {
return;
}
@ -289,7 +289,7 @@ function setupButtonFunctionality() {
return;
}
// Encrypt the pastes if needed
// Encrypt the paste if needed
let value = INPUT_ELEMENT.value;
let metadata;
let key;
@ -305,20 +305,20 @@ function setupButtonFunctionality() {
key = encrypted.key;
}
// Try to create the pastes
// Try to create the paste
const response = await API.createPaste(value, metadata);
if (!response.ok) {
Notifications.error("Error while creating pastes: <b>" + await response.text() + "</b>");
Notifications.error("Error while creating paste: <b>" + await response.text() + "</b>");
return;
}
const data = await response.json();
// Display the modification token if provided
if (data.modificationToken) {
prompt("The modification token for your pastes is:", data.modificationToken);
prompt("The modification token for your paste is:", data.modificationToken);
}
// Redirect the user to his newly created pastes
// Redirect the user to his newly created paste
location.replace(location.protocol + "//" + location.host + "/" + data.id + (key ? "#" + key : ""));
});
});
@ -333,10 +333,10 @@ function setupButtonFunctionality() {
return;
}
// Try to delete the pastes
// Try to delete the paste
const response = await API.deletePaste(PASTE_ID, modificationToken);
if (!response.ok) {
Notifications.error("Error while deleting pastes: <b>" + await response.text() + "</b>");
Notifications.error("Error while deleting paste: <b>" + await response.text() + "</b>");
return;
}
@ -369,17 +369,17 @@ function setupButtonFunctionality() {
return;
}
// Re-encrypt the pastes data if needed
// Re-encrypt the paste data if needed
let value = INPUT_ELEMENT.value;
if (ENCRYPTION_KEY && ENCRYPTION_IV) {
const encrypted = await Encryption.encrypt(await Encryption.encryptionDataFromHex(ENCRYPTION_KEY, ENCRYPTION_IV), value);
value = encrypted.result;
}
// Try to edit the pastes
// Try to edit the paste
const response = await API.editPaste(PASTE_ID, modificationToken, value);
if (!response.ok) {
Notifications.error("Error while editing pastes: <b>" + await response.text() + "</b>");
Notifications.error("Error while editing paste: <b>" + await response.text() + "</b>");
return;
}
@ -387,13 +387,13 @@ function setupButtonFunctionality() {
CODE = INPUT_ELEMENT.value;
updateCode();
toggleEditMode();
Notifications.success("Successfully edited pastes.");
Notifications.success("Successfully edited paste.");
});
BUTTON_TOGGLE_ENCRYPTION_ELEMENT.addEventListener("click", () => {
const active = BUTTON_TOGGLE_ENCRYPTION_ELEMENT.classList.toggle("active");
localStorage.setItem("encryption", active);
Notifications.success((active ? "Enabled" : "Disabled") + " automatic pastes encryption.");
Notifications.success((active ? "Enabled" : "Disabled") + " automatic paste encryption.");
});
BUTTON_REPORT_ELEMENT.addEventListener("click", async () => {
@ -403,17 +403,17 @@ function setupButtonFunctionality() {
return;
}
// Try to report the pastes
// Try to report the paste
const response = await API.reportPaste(PASTE_ID, reason);
if (!response.ok) {
Notifications.error("Error while reporting pastes: <b>" + await response.text() + "</b>");
Notifications.error("Error while reporting paste: <b>" + await response.text() + "</b>");
return;
}
// Show the response message
const data = await response.json();
if (!data.success) {
Notifications.error("Error while reporting pastes: <b>" + data.message + "</b>");
Notifications.error("Error while reporting paste: <b>" + data.message + "</b>");
return;
}
Notifications.success(data.message);

View File

@ -0,0 +1,77 @@
package web
import (
"embed"
"errors"
"io"
"io/fs"
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
)
//go:embed frontend/*
var frontend embed.FS
func frontendHandler(notFoundHandler http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
path := strings.TrimSpace(strings.TrimSuffix(request.URL.Path, "/"))
isFirstLevel := strings.Count(path, "/") <= 1
file, err := frontend.Open(filepath.Join("frontend", path))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if isFirstLevel {
serveIndexFile(writer, request)
} else {
notFoundHandler(writer, request)
}
return
}
writeErr(writer, err)
return
}
defer func() {
_ = file.Close()
}()
fileInfo, err := file.Stat()
if err != nil {
writeErr(writer, err)
return
}
if fileInfo.IsDir() {
if isFirstLevel {
serveIndexFile(writer, request)
} else {
notFoundHandler(writer, request)
}
return
}
content, err := io.ReadAll(file)
if err != nil {
writeErr(writer, err)
return
}
writer.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(fileInfo.Name())))
writer.Header().Set("Content-Length", strconv.Itoa(len(content)))
_, _ = writer.Write(content)
}
}
func serveIndexFile(writer http.ResponseWriter, _ *http.Request) {
indexFile, err := frontend.ReadFile("frontend/index.html")
if err != nil {
writeErr(writer, err)
return
}
writer.Header().Set("Content-Type", "text/html")
writer.Header().Set("Content-Length", strconv.Itoa(len(indexFile)))
_, _ = writer.Write(indexFile)
}

View File

@ -1,7 +1,10 @@
package web
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/lus/pasty/internal/meta"
"github.com/lus/pasty/internal/pastes"
"github.com/lus/pasty/internal/storage"
"net/http"
)
@ -34,16 +37,53 @@ type Server struct {
// The administration tokens.
AdminTokens []string
httpServer *http.Server
}
func (server *Server) Start() error {
router := chi.NewRouter()
// Register the web frontend handler
router.Get("/*", frontendHandler(router.NotFoundHandler()))
// Register the raw paste handler
router.With(server.v2MiddlewareInjectPaste).Get("/{paste_id}/raw", func(writer http.ResponseWriter, request *http.Request) {
paste, ok := request.Context().Value("paste").(*pastes.Paste)
if !ok {
writeString(writer, http.StatusInternalServerError, "missing paste object")
return
}
_, _ = writer.Write([]byte(paste.Content))
})
// Register the paste API endpoints
router.Get("/api/*", router.NotFoundHandler())
router.With(server.v2MiddlewareInjectPaste).Get("/api/v2/pastes/{paste_id}", server.v2EndpointGetPaste)
router.Post("/api/v2/pastes", server.v2EndpointCreatePaste)
router.With(server.v2MiddlewareInjectPaste, server.v2MiddlewareAuthorize).Patch("/api/v2/pastes/{paste_id}", server.v2EndpointModifyPaste)
router.Delete("/api/v2/pastes/{paste_id}", server.v2EndpointDeletePaste)
router.With(server.v2MiddlewareInjectPaste, server.v2MiddlewareAuthorize).Delete("/api/v2/pastes/{paste_id}", server.v2EndpointDeletePaste)
router.Get("/api/v2/info", func(writer http.ResponseWriter, request *http.Request) {
writeJSONOrErr(writer, http.StatusOK, map[string]any{
"version": meta.Version,
"modificationTokens": server.ModificationTokensEnabled,
"reports": false, // TODO: Return report state
"pasteLifetime": -1, // TODO: Return paste lifetime
})
})
return http.ListenAndServe(server.Address, router)
// Start the HTTP server
server.httpServer = &http.Server{
Addr: server.Address,
Handler: router,
}
return server.httpServer.ListenAndServe()
}
func (server *Server) Shutdown(ctx context.Context) error {
if err := server.httpServer.Shutdown(ctx); err != nil {
return err
}
server.httpServer = nil
return nil
}