Compare commits

..

49 Commits

Author SHA1 Message Date
Daniel Legt 3a6c796cac * Reverse Proxy Warning 2022-06-14 23:59:34 +03:00
Daniel Legt 6376fa9128 * Removed the header pass-through 2022-06-13 22:19:49 +03:00
Daniel Legt ca10033ecf Merge branch 'master' of https://github.com/JustKato/FreePad 2022-06-07 02:42:03 +03:00
Daniel Legt b68e5c8e88 REVERT 2022-06-07 02:41:31 +03:00
Daniel Legt 84ccd44fd7 Merge branch 'master' of https://github.com/JustKato/FreePad 2022-06-07 02:26:58 +03:00
Daniel Legt 0a1bff5cd4 * Branch reset 2022-06-07 02:26:55 +03:00
Daniel Legt 7982d564e8 * 2022-06-07 02:25:31 +03:00
Daniel Legt becbf30752 . 2022-06-07 02:04:21 +03:00
Daniel Legt 796a3b07c4 * Debug on live 2022-06-07 01:53:47 +03:00
Daniel Legt 5518103575 * Cleanup 2022-06-07 01:44:48 +03:00
Daniel Legt 177ab62720 * Hopefully a fix 2022-06-07 01:40:25 +03:00
Daniel Legt 5a8ccf20a8 * Quick protocol fix client-side 2022-06-07 01:13:45 +03:00
Daniel Legt a71be11135
Merge pull request #17 from JustKato/feature/web-sockets
Feature/web sockets
2022-06-07 00:56:44 +03:00
Daniel Legt 30dc23c847 * Dynamic updates for highlights 2022-06-07 00:53:25 +03:00
Daniel Legt 0a3b5d50f2 * Default to scroll at the bottom of content 2022-06-07 00:39:05 +03:00
Daniel Legt b4c47ded35 * Better text update follow 2022-06-07 00:37:35 +03:00
Daniel Legt 6e401a416f * Fixed light pad title style 2022-06-07 00:29:30 +03:00
Daniel Legt 3b137c5ed6 * Fixed invisible edit button on Light Mode 2022-06-07 00:28:53 +03:00
Daniel Legt c4f6496e0e * Restyled the Pad title 2022-06-07 00:27:51 +03:00
Daniel Legt ee9516a109 * Fixed annoying alignment of edit/view 2022-06-07 00:20:26 +03:00
Daniel Legt 7dcad9dc31 * Archive Fix 2022-06-07 00:16:27 +03:00
Daniel Legt 1b1fe59877 * Fixed preview not updating 2022-06-07 00:04:49 +03:00
Daniel Legt 6cc1628e77 Real time WebSocket pasting
+ Pad update moved to sockets
+ Pad status updates instantly update
TODO: Fix live text highlight
2022-06-07 00:02:31 +03:00
Daniel Legt bf144c6ecb * Fixed possible bug 2022-06-06 22:50:20 +03:00
Daniel Legt 1d3383c8c6 * Removed deprecated variable 2022-06-06 22:49:55 +03:00
Daniel Legt 4138386fb3 * Some code notes 2022-06-04 16:36:30 +03:00
Daniel Legt cfe2c06dac WebSockets Work started
+ Implemented Gorilla Sockets
+ Implemented a javascript class
+ Golang Struct introduced
+ JSON parsing from the websocket
+ Fail handlers
2022-06-04 16:32:53 +03:00
Daniel Legt 400fd23b3e
Merge pull request #15 from JustKato/feature/admin-interface
Everything seems stable! Been tested just by myself, so if some other people could do some security testing, it'd be awesome!
2022-06-04 13:24:19 +03:00
Daniel Legt bf1d032e68 + Deletion implemented 2022-06-03 23:15:20 +03:00
Daniel Legt faff1ab527 + Deletion Confirmation 2022-06-03 22:59:44 +03:00
Daniel Legt d056a4d429 * Removed debug line 2022-06-03 22:56:53 +03:00
Daniel Legt b710d24a2d * Previous commit 2022-06-03 22:56:25 +03:00
Daniel Legt c3c9aacac3 + Admin interface
+ Pad Listing
@TODO: Add pagination
2022-06-03 22:56:19 +03:00
Daniel Legt d949b3decb Working on the admin interface
+ Implemented login token
+ Routing
+ Admin controller
+ Login Page
* Updated `.env` example
2022-06-02 23:53:32 +03:00
Daniel Legt 662dad90b7
Merge pull request #13 from JustKato/feature/dockerFileBuild
Dockerfile Build Improvements
2022-06-01 21:30:04 +03:00
Daniel Legt 1585d3b158 * Replaced alpine with Scratch
* Changed comments
* Used /src instead of /app twice
2022-06-01 21:28:57 +03:00
Daniel Legt 1d50efe3c6 Dockerfile Build Improvements 2022-06-01 21:24:03 +03:00
Daniel Legt 0f5a352fc6 * Docker Compose example updated 2022-06-01 18:43:35 +03:00
Daniel Legt 6a8f4f81e5 * Corrected view button position 2022-06-01 18:31:59 +03:00
Daniel Legt 781b4bcf80
Merge pull request #12 from JustKato/feature/syntax_highlight
Feature/syntax highlight
2022-06-01 18:29:48 +03:00
Daniel Legt f748adf132 * Version Bumb 2022-06-01 18:29:24 +03:00
Daniel Legt 4bfad3ef40 Changelog and Version bump 2022-06-01 18:29:17 +03:00
Daniel Legt 11658b4b5e Extra Text
+ Added extra text to the home
2022-06-01 18:28:17 +03:00
Daniel Legt 685c6ae15f * Optimization ( Thanks Tecchie088 ) 2022-06-01 18:10:10 +03:00
Daniel Legt 97102b98b3 * Fixed Security flaw ( Thanks @SleepingProcess ) 2022-06-01 18:08:17 +03:00
Daniel Legt 22657cc111 * Fixed Sanitization 2022-06-01 18:07:33 +03:00
Daniel Legt 3dc09cae64 # Updated Looks, Syntax Highlights and more
* Moved all CDN libraries into /static/vendor
+ Implemented syntax highlight with hljs
* Updated the sanitization to include `.`
2022-06-01 17:43:05 +03:00
Daniel Legt 70b671c0be + Pad Types::Downloads 2022-06-01 17:00:23 +03:00
Daniel Legt 6177dcecb8 Appearance updates
+ Improved scroll bar look
+ Implemented a preview mode
2022-06-01 16:58:09 +03:00
32 changed files with 2434 additions and 72 deletions

View File

@ -21,4 +21,8 @@ CLEANUP_MAX_AGE=43200 # Default is a month
# Maximum pad file lenght, this is in characters, a character is one byte. # Maximum pad file lenght, this is in characters, a character is one byte.
# Default: 524288 ( 500kb ) # Default: 524288 ( 500kb )
MAXIMUM_PAD_SIZE=524288 MAXIMUM_PAD_SIZE=524288
# Your admin access token
# If the value is not defined the admin interface will not be available
# ADMIN_TOKEN=SUPER_SECRET_ADMIN_TOKEN

View File

@ -1,3 +1,6 @@
# 1.4.0 🖌
Syntax highlight has been implemented, as well as a couple security concerns being patched. Thank you to everyone that has helped me out with those
# 1.3.0 👀 # 1.3.0 👀
Implemented a views system, now everyone can see how many times a pad has been accessed, an auto-save has also been added for those views to file in the `data` dir. Implemented a views system, now everyone can see how many times a pad has been accessed, an auto-save has also been added for those views to file in the `data` dir.

View File

@ -1,9 +1,27 @@
FROM alpine # Importing golang 1.18 to use as a builder for our source
FROM golang:1.18 as builder
LABEL version="1.3.0" # Use the /src directory as a workdir
WORKDIR /src
# Copy the distribution files # Copy the src to /src
COPY ./dist /app COPY . ./
# Download dependencies
RUN go mod download
# Build the executable
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o freepad .
# Import alpine linux as a base
FROM scratch
LABEL version="1.4.0"
# Copy the files from the builder to the new image
COPY --from=builder /src/freepad /app/freepad
COPY --from=builder /src/templates /app/templates
COPY --from=builder /src/static /app/static
# Make /app the work directory # Make /app the work directory
WORKDIR /app WORKDIR /app

View File

@ -19,6 +19,29 @@ The project is absolutely free to use, you can extend the code and even contribu
The current maintainer and creator is `Kato Twofold` The current maintainer and creator is `Kato Twofold`
# 🛑 About reverse proxying 🛑
If you are looking to reverse proxy this program, please keep in mind that the websockets have specific settings regarding reverse proxying, I have tried using `Apache2` but to no luck, if someone could give a suggestion as to how to set up my own program on `Apache2` it'd be amazing.
On `Nginx` it's rather simple, here is my reverse proxy for the demo at [pad.justkato.me](https://pad.justkato.me/)
```nginx
server {
# Define the basic information such as server name and log location
server_name pad.justkato.me
access_log logs/pad.justkato.me.access.log main;
# setup the reverse proxy
location / {
proxy_pass http://127.0.0.1:1626;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support !! Important !!
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
![Gopher](static/img/banner_prerequisites.png) ![Gopher](static/img/banner_prerequisites.png)

View File

@ -3,13 +3,13 @@ version: '3'
services: services:
freepad: freepad:
# Uncomment the bellow to use the production docker image from the docker repository # Uncomment the bellow to use the production docker image from the docker repository
# image: image: justkato/freepad
# Comment the build line if you are just looking to use a docker-compose file # Comment the build line if you are just looking to use a docker-compose file
build: . # build: .
# I don't recommend changing the 8080 as there would be no reason to, # I don't recommend changing the 8080 as there would be no reason to,
# simply change the 3113 port to anything you would like for the container to listen on # simply change the 3113 port to anything you would like for the container to listen on
ports: ports:
- 3113:8080 - 8080:8080
# This will read from your .env variables, in that file you will find the documentation as well # This will read from your .env variables, in that file you will find the documentation as well
environment: environment:
- DOMAIN_BASE - DOMAIN_BASE

2
go.mod
View File

@ -4,6 +4,8 @@ go 1.15
require ( require (
github.com/gin-gonic/gin v1.7.7 github.com/gin-gonic/gin v1.7.7
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/joho/godotenv v1.4.0 github.com/joho/godotenv v1.4.0
github.com/mrz1836/go-sanitize v1.1.5 github.com/mrz1836/go-sanitize v1.1.5
github.com/ulule/limiter/v3 v3.10.0 github.com/ulule/limiter/v3 v3.10.0

4
go.sum
View File

@ -38,6 +38,10 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

View File

@ -0,0 +1,62 @@
package controllers
import (
"crypto/sha512"
"encoding/hex"
"fmt"
"net/http"
"github.com/JustKato/FreePad/lib/helper"
"github.com/gin-gonic/gin"
)
func AdminMiddleware(router *gin.RouterGroup) {
// Handl
router.Use(func(ctx *gin.Context) {
// Check which route we are accessing
fmt.Println(`Accesing: `, ctx.Request.RequestURI)
// Check if the request is other than the login request
if ctx.Request.RequestURI != "/admin/login" {
// Check if the user is logged-in
fmt.Println(`Checking if admin`)
if !IsAdmin(ctx) {
// Not an admin, redirect to homepage
ctx.Redirect(http.StatusTemporaryRedirect, "/")
ctx.Abort()
fmt.Println(`Not an admin!`)
return
}
}
})
}
func IsAdmin(ctx *gin.Context) bool {
adminToken, err := ctx.Cookie("admin_token")
if err != nil {
return false
}
// Encode the real token
sha512Hasher := sha512.New()
sha512Hasher.Write([]byte(helper.GetAdminToken()))
hashHexToken := sha512Hasher.Sum(nil)
trueToken := hex.EncodeToString(hashHexToken)
// Check if the user's admin token matches the token
if adminToken != "" && adminToken == trueToken {
// Yep, it's the admin!
return true
}
// Definitely not an admin
return false
}

View File

@ -6,7 +6,7 @@ func ApplyHeaders(router *gin.Engine) {
router.Use(func(ctx *gin.Context) { router.Use(func(ctx *gin.Context) {
// Apply the header // Apply the header
ctx.Header("FreePad-Version", "1.3.0") ctx.Header("FreePad-Version", "1.4.0")
// Move on // Move on
ctx.Next() ctx.Next()

View File

@ -72,3 +72,18 @@ func GetCacheMapLimit() int {
return rez return rez
} }
// Get the admin token used to authenticate as an admin
func GetAdminToken() string {
// Get the admin login from the environment
adminToken, exists := os.LookupEnv("ADMIN_TOKEN")
// Check if the admin token was defined
if !exists {
// The admin token was not defined, disable admin logins
return ""
}
// Return the admin token
return adminToken
}

View File

@ -26,6 +26,13 @@ type Post struct {
Views uint32 `json:"views"` Views uint32 `json:"views"`
} }
func (p *Post) Delete() error {
filePath := path.Join(getStorageDirectory(), p.Name)
// Remove the file and return the result
return os.Remove(filePath)
}
// Get the path to the views JSON // Get the path to the views JSON
func getViewsFilePath() (string, error) { func getViewsFilePath() (string, error) {
// Get the path to the storage then append the const name for the storage file // Get the path to the storage then append the const name for the storage file
@ -34,7 +41,7 @@ func getViewsFilePath() (string, error) {
// Check if the file exists // Check if the file exists
if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) {
// Create the file // Create the file
err := os.WriteFile(filePath, []byte(""), 0777) err := os.WriteFile(filePath, []byte(""), 0640)
if err != nil { if err != nil {
return ``, err return ``, err
} }
@ -94,7 +101,7 @@ func LoadViewsCache() error {
return nil return nil
} }
func AddViewToPost(postName string) uint32 { func AddViewToPost(postName string, incrementViews bool) uint32 {
// Lock the viewers mapping // Lock the viewers mapping
viewersLock.Lock() viewersLock.Lock()
@ -104,8 +111,10 @@ func AddViewToPost(postName string) uint32 {
ViewsCache[postName] = 0 ViewsCache[postName] = 0
} }
// Add to the counter if incrementViews {
ViewsCache[postName]++ // Add to the counter
ViewsCache[postName]++
}
// Unlock // Unlock
viewersLock.Unlock() viewersLock.Unlock()
@ -126,7 +135,7 @@ func SavePostViewsCache() error {
return err return err
} }
f, err := os.OpenFile(viewsFilePath, os.O_WRONLY|os.O_CREATE, 0777) f, err := os.OpenFile(viewsFilePath, os.O_WRONLY|os.O_CREATE, 0640)
if err != nil { if err != nil {
return err return err
} }
@ -162,7 +171,7 @@ func getStorageDirectory() string {
// Check if the base storage path exists // Check if the base storage path exists
if _, err := os.Stat(baseStoragePath); os.IsNotExist(err) { if _, err := os.Stat(baseStoragePath); os.IsNotExist(err) {
// Looks like the base storage path was NOT set, create the dir // Looks like the base storage path was NOT set, create the dir
err = os.Mkdir(baseStoragePath, 0777) err = os.Mkdir(baseStoragePath, 0640)
// Check for errors // Check for errors
if err != nil { if err != nil {
// No way this sends an error unless it goes horribly wrong. // No way this sends an error unless it goes horribly wrong.
@ -175,7 +184,7 @@ func getStorageDirectory() string {
} }
// Get a post from the file system // Get a post from the file system
func GetPost(fileName string) Post { func GetPost(fileName string, incrementViews bool) Post {
// Get the base storage directory and make sure it exists // Get the base storage directory and make sure it exists
storageDir := getStorageDirectory() storageDir := getStorageDirectory()
@ -183,7 +192,7 @@ func GetPost(fileName string) Post {
filePath := fmt.Sprintf("%s%s", storageDir, fileName) filePath := fmt.Sprintf("%s%s", storageDir, fileName)
// Get the post views and add 1 to them // Get the post views and add 1 to them
postViews := AddViewToPost(fileName) postViews := AddViewToPost(fileName, incrementViews)
p := Post{ p := Post{
Name: fileName, Name: fileName,
@ -233,8 +242,6 @@ func WritePost(p Post) error {
if err != nil { if err != nil {
return err return err
} }
// Actually close the file
defer f.Close()
// Write the contnets // Write the contnets
_, err = f.WriteString(p.Content) _, err = f.WriteString(p.Content)
@ -242,11 +249,7 @@ func WritePost(p Post) error {
return err return err
} }
if err := f.Close(); err != nil { return f.Close()
return err
}
return nil
} }
// Cleanup all of the older posts based on the environment settings // Cleanup all of the older posts based on the environment settings
@ -301,3 +304,30 @@ func CleanupPosts(age int) {
} }
} }
func GetAllPosts() []Post {
// Initialize the list of posts
postList := []Post{}
// Get the posts storage directory
storageDir := getStorageDirectory()
// Read the directory listing
files, err := os.ReadDir(storageDir)
// Check if thereh as been an issues with reading the directory contents
if err != nil {
// Log the error
fmt.Println("Error::GetAllPosts:", err)
// Return an empty list to have a clean fallback
return []Post{}
}
// Go through all of the files
for _, v := range files {
// Process the file into a pad
postList = append(postList, GetPost(v.Name(), false))
}
// Return the post list
return postList
}

View File

@ -0,0 +1,95 @@
package routes
import (
"encoding/hex"
"fmt"
"net/http"
"github.com/JustKato/FreePad/lib/controllers"
"github.com/JustKato/FreePad/lib/helper"
"github.com/JustKato/FreePad/lib/objects"
"github.com/gin-gonic/gin"
"crypto/sha512"
)
var adminLoginToken string = ""
func AdminRoutes(router *gin.RouterGroup) {
adminLoginToken = helper.GetAdminToken()
// Apply the admin middleware for identification
controllers.AdminMiddleware(router)
// Admin login route
router.GET("/login", func(ctx *gin.Context) {
ctx.HTML(200, "admin_login.html", gin.H{
"title": "Login Login",
"domain_base": helper.GetDomainBase(),
})
})
router.POST("/login", func(ctx *gin.Context) {
// Get the value of the admin token
adminToken := ctx.PostForm("admin-token")
// Check if the input admin token matches our admin token
if adminLoginToken != "" && adminLoginToken == adminToken {
sha512Hasher := sha512.New()
sha512Hasher.Write([]byte(adminToken))
// Set the cookie to be an admin
hashHexToken := sha512Hasher.Sum(nil)
hashToken := hex.EncodeToString(hashHexToken)
// Set the cookie
ctx.SetCookie("admin_token", hashToken, 60*60, "/", helper.GetDomainBase(), true, true)
ctx.Request.Method = "GET"
// Redirect the user to the admin page
ctx.Redirect(http.StatusFound, "/admin/view")
return
} else {
ctx.Request.Method = "GET"
// Redirect the user to the admin page
ctx.Redirect(http.StatusFound, "/admin/login?fail")
return
}
})
router.GET("/delete/:padname", func(ctx *gin.Context) {
// Get the pad name that we bout' to delete
padName := ctx.Param("padname")
// Try and get the pad, check if valid
pad := objects.GetPost(padName, false)
// Delete the pad
err := pad.Delete()
fmt.Println(err)
// Redirect the user to the admin page
ctx.Redirect(http.StatusFound, "/admin/view")
})
// Admin view route
router.GET("/view", func(ctx *gin.Context) {
// Get all of the pads as a listing
padList := objects.GetAllPosts()
ctx.HTML(200, "admin_view.html", gin.H{
"title": "Admin",
"padList": padList,
"domain_base": helper.GetDomainBase(),
})
})
}

View File

@ -39,9 +39,9 @@ func HomeRoutes(router *gin.Engine) {
if err == nil { if err == nil {
postName = newPostName postName = newPostName
} }
postName = sanitize.AlphaNumeric(postName, true) postName = sanitize.XSS(sanitize.SingleLine(postName))
post := objects.GetPost(postName) post := objects.GetPost(postName, true)
c.HTML(200, "page.html", gin.H{ c.HTML(200, "page.html", gin.H{
"title": postName, "title": postName,
@ -63,7 +63,7 @@ func HomeRoutes(router *gin.Engine) {
if err == nil { if err == nil {
postName = newPostName postName = newPostName
} }
postName = sanitize.AlphaNumeric(postName, true) postName = sanitize.XSS(sanitize.SingleLine(postName))
p := objects.Post{ p := objects.Post{
Name: postName, Name: postName,

View File

@ -0,0 +1,197 @@
package socketmanager
import (
"encoding/json"
"errors"
"fmt"
"github.com/JustKato/FreePad/lib/objects"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024, // TODO: Make it configurable via the .env file
WriteBufferSize: 1024, // TODO: Make it configurable via the .env file
}
// The pad socket map caches all of the existing sockets
var padSocketMap map[string]map[string]*websocket.Conn = make(map[string]map[string]*websocket.Conn)
// TODO: Use generics so that we can take string messages, that'd be nice!
type SocketMessage struct {
EventType string `json:"eventType"`
PadName string `json:"padName"`
Message map[string]interface{} `json:"message"`
}
// Bind the websockets to the gin router
func BindSocket(router *gin.RouterGroup) {
router.GET("/get/:pad", func(ctx *gin.Context) {
// Get the name of the pad to assign to this socket
padName := ctx.Param("pad")
// Upgrade the socket connection
webSocketUpgrade(ctx, padName)
})
}
func webSocketUpgrade(ctx *gin.Context, padName string) {
conn, err := wsUpgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
fmt.Printf("Failed to set websocket upgrade: %v\n", err)
return
}
// Check if we have any sockets in this padName
if _, ok := padSocketMap[padName]; !ok {
// Initialize a new map of sockets
padSocketMap[padName] = make(map[string]*websocket.Conn)
}
// Give this socket a token
socketToken := uuid.NewString()
// Set the current connection at the socket Token position
padSocketMap[padName][socketToken] = conn
// Somone just connected
UpdatePadStatus(padName)
// Start listening to this socket
for {
// Try Read the JSON input from the socket
_, msg, err := conn.ReadMessage()
// Check if anything but a read limit was created
if err != nil && !errors.Is(err, websocket.ErrReadLimit) {
// Remove self from the cache
delete(padSocketMap[padName], socketToken)
// Somone just disconnected
UpdatePadStatus(padName)
break
}
if err != nil {
// There has been an error reading the message
fmt.Println("Failed to read from the socket but probably still connected")
// Skip this cycle
continue
}
// Init the variable
var p SocketMessage
// Try and parse the json
err = json.Unmarshal([]byte(msg), &p)
if err != nil {
// There has been an error reading the message
fmt.Println("Failed to parse the JSON", err)
// Skip this cycle
continue
}
// Pass the message to the proper handlers
handleSocketMessage(p, socketToken, padName)
}
}
// Handle the socket's message
func handleSocketMessage(msg SocketMessage, socketToken string, padName string) {
// Check if this is a pad Update
if msg.EventType == `padUpdate` {
handlePadUpdate(msg, socketToken, padName)
// Serialize the message
serialized, err := json.Marshal(msg)
// Check if there was an error
if err != nil {
fmt.Println(`Failed to broadcast the padUpdate`, err)
// Stop the execution
return
}
// Alert all the other pads other than this one.
for k, pad := range padSocketMap[padName] {
// Check if this is the same socket.
if k == socketToken {
// Skip self
continue
}
// Send the message to the others.
pad.WriteMessage(websocket.TextMessage, serialized)
}
}
}
func handlePadUpdate(msg SocketMessage, socketToken string, padName string) {
// Check if the msg content is valid
if _, ok := msg.Message[`content`]; !ok {
fmt.Printf("Failed to update pad %s, invalid message\n", padName)
return
}
// Check that the content is string
newPadContent, ok := msg.Message[`content`].(string)
if !ok {
fmt.Printf("Type assertion failed for %s, invalid message\n", padName)
return
}
// Get the pad
pad := objects.GetPost(padName, false)
// Update the pad contents
pad.Content = newPadContent
// Save to file
objects.WritePost(pad)
}
// Update the current users of the pad about the amount of live viewers.
func UpdatePadStatus(padName string) {
// Grab info about the map's key
sockets, ok := padSocketMap[padName]
// Check if the pad is set and has sockets connected.
if !ok || len(sockets) < 1 {
// Quit
return
}
// Generate the message
msg := SocketMessage{
EventType: `statusUpdate`,
PadName: padName,
Message: gin.H{
// Send the current amount of live viewers
"currentViewers": len(sockets),
},
}
BroadcastMessage(padName, msg)
}
func BroadcastMessage(padName string, msg SocketMessage) {
// Grab info about the map's key
sockets, ok := padSocketMap[padName]
// Check if the pad is set and has sockets connected.
if !ok || len(sockets) < 1 {
// Quit
return
}
// Get all the participants of the pad group
for _, s := range sockets {
// Send the message to the socket
s.WriteJSON(msg)
}
}

View File

@ -7,6 +7,7 @@ import (
"github.com/JustKato/FreePad/lib/controllers" "github.com/JustKato/FreePad/lib/controllers"
"github.com/JustKato/FreePad/lib/objects" "github.com/JustKato/FreePad/lib/objects"
"github.com/JustKato/FreePad/lib/routes" "github.com/JustKato/FreePad/lib/routes"
"github.com/JustKato/FreePad/lib/socketmanager"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@ -46,9 +47,15 @@ func main() {
// Implement the rate limiter // Implement the rate limiter
controllers.DoRateLimit(router) controllers.DoRateLimit(router)
// Admin Routing
routes.AdminRoutes(router.Group("/admin"))
// Add Routes // Add Routes
routes.HomeRoutes(router) routes.HomeRoutes(router)
// Bind the Web Sockets
socketmanager.BindSocket(router.Group("/ws"))
router.Run(":8080") router.Run(":8080")
} }

View File

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300&display=swap');
:root { :root {
--color-border-default: #444c56; --color-border-default: #444c56;
--color-fg-default: #adbac7; --color-fg-default: #adbac7;
@ -35,6 +37,74 @@ main#main-card {
right: .5rem; right: .5rem;
} }
.hidden {
display: none !important;
}
#pad-content, #textarea-preview {
tab-size: 2;
font-family: 'Roboto Mono', monospace !important;
padding-top: 2rem;
}
#padTitle {
padding: .3rem .75rem !important;
border-radius: .25rem;
display: inline-block;
}
.dark #padTitle {
color: #db3384;
background-color: rgba(0, 0, 0, 0.10);
}
.light #padTitle {
color: #555273;
border: 1px solid rgba(0, 0, 0, 0.15);
}
#pad-content-area {
position: relative;
display: flex;
flex-flow: column;
}
.light .edit-content-text {
color: white !important;
}
.edit-content-text {
display: none;
}
.read-only-content .edit-content-text {
margin-top: 1rem;
display: block !important;
}
.read-only-content .view-content-text {
display: none !important;
}
#pad-content-toggler {
position: absolute;
top: .5rem;
right: .5rem;
user-select: none;
}
#textarea-preview {
max-height: calc(17rem + 30vh);
min-height: 17rem;
overflow: auto;
padding-top: 2rem;
margin-top: 1rem;
}
textarea:focus, textarea:focus,
input[type="text"]:focus, input[type="text"]:focus,
@ -55,4 +125,28 @@ input[type="color"]:focus,
border-color: none; border-color: none;
box-shadow: none; box-shadow: none;
outline: 0 none; outline: 0 none;
} }
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #3b3b3b #ffffff00;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-track {
background: #ffffff00;
}
*::-webkit-scrollbar-thumb {
background-color: #3b3b3b;
border-radius: 2px;
border: 1px solid #ffffff00;
}

View File

@ -3,12 +3,12 @@ function sendMyData(el) {
const formData = new FormData(); const formData = new FormData();
// Check if the writing watch was sending something already // Check if the writing watch was sending something already
if ( !!window.writingWatch ) { if (!!window.writingWatch) {
// Clear old timeout // Clear old timeout
clearTimeout(window.writingWatch); clearTimeout(window.writingWatch);
} }
if ( el.value.length > maximumPadSize ) { if (el.value.length > maximumPadSize) {
let err = new Error(`Your Pad is too big! Please keep it limited to ${maximumPadSize} characters!`); let err = new Error(`Your Pad is too big! Please keep it limited to ${maximumPadSize} characters!`);
alert(err); alert(err);
throw err; throw err;
@ -16,6 +16,13 @@ function sendMyData(el) {
el.setAttribute(`readonly`, `1`); el.setAttribute(`readonly`, `1`);
const textareaPreview = document.getElementById(`textarea-preview`)
if (!!textareaPreview) {
textareaPreview.textContent = el.value;
hljs.highlightElement(document.getElementById(`textarea-preview`));
}
formData.set("content", el.value); formData.set("content", el.value);
updateStatus(`Attempting to save...`, `text-warning`); updateStatus(`Attempting to save...`, `text-warning`);
@ -24,36 +31,36 @@ function sendMyData(el) {
body: formData, body: formData,
method: "post", method: "post",
}) })
.then( resp => { .then(resp => {
resp.json() resp.json()
.then( e => { .then(e => {
document.getElementById(`last_modified_`).value = e.pad.last_modified; document.getElementById(`last_modified_`).value = e.pad.last_modified;
updateStatus(`Succesfully Saved`, `text-success`); updateStatus(`Succesfully Saved`, `text-success`);
})
.catch(err => {
updateStatus(`Failed to Save`, `text-danger`);
console.error(err);
})
}) })
.catch( err => { .catch(err => {
updateStatus(`Failed to Save`, `text-danger`); updateStatus(`Failed to Save`, `text-danger`);
console.error(err); console.error(err);
}) })
}) .finally(() => {
.catch( err => { el.removeAttribute(`readonly`);
updateStatus(`Failed to Save`, `text-danger`); })
console.error(err);
})
.finally( () => {
el.removeAttribute(`readonly`);
})
} }
function toggleWritingWatch(el) { function toggleWritingWatch(el) {
// Check if the writing watch was sending something already // Check if the writing watch was sending something already
if ( !!window.writingWatch ) { if (!!window.writingWatch) {
// Clear old timeout // Clear old timeout
clearTimeout(window.writingWatch); clearTimeout(window.writingWatch);
} }
// Set a timeout for the action // Set a timeout for the action
window.writingWatch = setTimeout( () => { window.writingWatch = setTimeout(() => {
// Send out the data // Send out the data
sendMyData(el) sendMyData(el)
}, 750) }, 750)
@ -74,7 +81,7 @@ function getLocalArchives() {
let a = localStorage.getItem(`${padTitle}_archives`); let a = localStorage.getItem(`${padTitle}_archives`);
// Check if we had anything in storage for the archives // Check if we had anything in storage for the archives
if ( a == null ) { if (a == null) {
// There were nothing in storage // There were nothing in storage
return []; return [];
} }
@ -82,7 +89,7 @@ function getLocalArchives() {
try { try {
// Try and parse the json // Try and parse the json
a = JSON.parse(a); a = JSON.parse(a);
} catch ( err ) { } catch (err) {
// Return null of the fail // Return null of the fail
return []; return [];
} }
@ -93,7 +100,7 @@ function getLocalArchives() {
function storeArchives(archives) { function storeArchives(archives) {
// Check if the provided list is an array // Check if the provided list is an array
if ( !Array.isArray(archives) ) return; if (!Array.isArray(archives)) return;
// Set the current archives // Set the current archives
localStorage.setItem(`${padTitle}_archives`, JSON.stringify(archives)); localStorage.setItem(`${padTitle}_archives`, JSON.stringify(archives));
@ -105,13 +112,13 @@ function renderArchivesSelection() {
const archivesSelection = document.getElementById(`archives-selection`); const archivesSelection = document.getElementById(`archives-selection`);
const rowTemplate = document.getElementById(`archive-selection-example`); const rowTemplate = document.getElementById(`archive-selection-example`);
// Clear any old optiosn // Clear any old optiosn
archivesSelection.querySelectorAll(`.dropdown-item:not(#do-archive-button):not(#archive-selection-example)`).forEach( el => { archivesSelection.querySelectorAll(`.dropdown-item:not(#do-archive-button):not(#archive-selection-example)`).forEach(el => {
// Remove the element // Remove the element
el.remove(); el.remove();
}) })
// Get the current list of available archives // Get the current list of available archives
for ( let a of getLocalArchives() ) { for (let a of getLocalArchives()) {
// Clone the template row // Clone the template row
const row = rowTemplate.cloneNode(true); const row = rowTemplate.cloneNode(true);
@ -129,9 +136,12 @@ function renderArchivesSelection() {
row.addEventListener(`click`, e => { row.addEventListener(`click`, e => {
let resp = confirm("Load contents of pad from memory? This will overwrite the current pad for everyone."); let resp = confirm("Load contents of pad from memory? This will overwrite the current pad for everyone.");
if ( !!resp ) { if (!!resp) {
document.getElementById(`pad-content`).value = a.content; // Update visually for the client
updatePadContent(a.content);
// Send the update
window.socket.sendPadUpdate();
} }
}) })
@ -142,7 +152,7 @@ function renderArchivesSelection() {
function saveLocalArchive() { function saveLocalArchive() {
let resp = confirm("Save a local copy of the current Pad?"); let resp = confirm("Save a local copy of the current Pad?");
if ( !resp ) { if (!resp) {
// Do not // Do not
return; return;
} }
@ -173,7 +183,7 @@ function generateQRCode() {
// Add new qr // Add new qr
new QRCode(qrcodeContainer, { new QRCode(qrcodeContainer, {
text: window.location.toString(), text: window.location.toString(),
width: 256, width: 256,
height: 256, height: 256,
colorDark: "#555273", colorDark: "#555273",
colorLight: "#ffffff", colorLight: "#ffffff",
@ -184,11 +194,37 @@ function generateQRCode() {
MicroModal.show(`qrmodal`) MicroModal.show(`qrmodal`)
} }
document.addEventListener(`DOMContentLoaded`, e => { function toggleTextareaPreview() {
setTextareaPreview(!document.getElementById(`pad-content-toggler`).classList.contains(`read-only`))
}
{ // Textarea Focusing // t == true - Read Only
const textarea = document.getElementById(`pad-content`); // t == false - Edit mode
function setTextareaPreview(t = true) {
const prev = document.getElementById(`textarea-preview`)
const textarea = document.getElementById(`pad-content`);
const toggler = document.getElementById(`pad-content-toggler`);
const padContentArea = document.getElementById(`pad-content-area`);
if (t) {
// Toggle read only
prev.classList.remove(`hidden`)
toggler.classList.add(`read-only`);
padContentArea.classList.add(`read-only-content`);
prev.scrollTop = prev.scrollHeight;
textarea.classList.add(`hidden`);
} else {
// Toggle edit mode
prev.classList.add(`hidden`)
toggler.classList.remove(`read-only`);
padContentArea.classList.remove(`read-only-content`);
textarea.classList.remove(`hidden`);
// Focus // Focus
textarea.focus(); textarea.focus();
// Scroll // Scroll
@ -197,6 +233,38 @@ document.addEventListener(`DOMContentLoaded`, e => {
textarea.setSelectionRange(textarea.value.length, textarea.value.length); textarea.setSelectionRange(textarea.value.length, textarea.value.length);
} }
}
document.addEventListener(`DOMContentLoaded`, e => {
{ // Textarea Handling
const textarea = document.getElementById(`pad-content`);
setTextareaPreview(!!textarea.value);
// Make sure tabs are taken into consideration
textarea.addEventListener('keydown', function (e) {
if (e.key == 'Tab') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
// set textarea value to: text before caret + tab + text after caret
this.value = this.value.substring(0, start) +
"\t" + this.value.substring(end);
// put caret at right position again
this.selectionStart =
this.selectionEnd = start + 1;
}
});
}
try { // highlights
hljs.highlightElement(document.getElementById(`textarea-preview`));
} catch ( err ) {
console.err(err);
}
{ // Archives { // Archives
renderArchivesSelection() renderArchivesSelection()
} }

View File

@ -14,8 +14,14 @@ class Pad {
// Create a new blob of the contents of the pad // Create a new blob of the contents of the pad
var blob = new Blob([ document.getElementById(`pad-content`).value ], { type: "text/plain;charset=utf-8" }); var blob = new Blob([ document.getElementById(`pad-content`).value ], { type: "text/plain;charset=utf-8" });
let downloadFileName = this.title;
if ( !this.title.includes(`.`) ) {
// Append a default file format
downloadFileName += `.txt`;
}
// Save the blob as // Save the blob as
saveAs(blob, `${this.title}.txt`); saveAs(blob, `${downloadFileName}`);
} }
} }

261
static/js/ws.js Normal file
View File

@ -0,0 +1,261 @@
class PadSocket {
/**
* @type {WebSocket}
*/
ws = null;
/**
* @type {String}
*/
padName = null;
/**
* The actual textarea you write in
* @type {HTMLTextAreaElement}
*/
padContents = null;
/**
* The <code> of the preview
* @type {HTMLElement}
*/
padPreview = null;
/**
* Create a new PadSocket
* @param {string} padName The name of the pad
* @param {string} connUrl The URL to the websocket
*/
constructor(padName, connUrl = null) {
// Assign the pad name
this.padName = padName;
// Check if a connection URL was mentioned
if ( connUrl == null ) {
let connProtocol = `ws://`;
if ( window.location.protocol == `https:` ) {
connProtocol = `wss://`;
}
// Try and connect to the local websocket
connUrl = connProtocol + window.location.host + `/ws/get/${padName}`;
}
// Connect to the websocket
const ws = new WebSocket(connUrl);
// Bind the onMessage function
ws.onmessage = this.handleMessage;
ws.onopen = () => {
updateStatus(`Established`, `text-success`);
}
function onFail() {
updateStatus(`Connection Failed`, `text-dangerous`);
}
// Try and reconnect on failure
ws.onclose = onFail;
ws.onerror = onFail;
// Assign the websocket
this.ws = ws;
// Get all relevant references from the HTML
this.padContents = document.getElementById(`pad-content`);
this.padPreview = document.getElementById(`textarea-preview`);
}
/**
* @description Send a message to the server
* @param {string} eventType The type of event, this can be anything really, it's just used for routing by the server
* @param {Object} message The message to send out to the server, this can only be of format string but JSON is parsed.
*/
sendMessage = (eventType, message) => {
if ( this.ws.readyState !== WebSocket.OPEN ) {
throw new Error(`The websocket connection is not active`);
}
// Check if the message is a string
if ( typeof message !== 'object' ) {
// Convert the message into a map[string]interface{}
message = {
"message": message,
};
}
// TODO: Compress the message, usually we will be sending the whole body of the pad from the client to the server or vice-versa.
this.ws.send( JSON.stringify({
eventType,
padName: this.padName,
message,
}))
}
/**
* Handle the message from the socket based on the message type
* @param {MessageEvent} e The websocket message
*/
handleMessage = ev => {
updateStatus(`Catching Message`, `text-white`);
// Check if the message has valid data
if ( !!ev.data ) {
// Try and parse the data
let parsedData = null;
try {
parsedData = JSON.parse(ev.data);
} catch ( err ) {
console.error(`Failed to parse the WebSocket data`,err);
updateStatus(`Parse Fail`, `text-warning`);
}
if ( !!!parsedData['message'] ) {
console.error(`Failed to find the message`)
updateStatus(`Message Fail`, `text-warning`);
return;
}
// Check if this is a pad Content Update
if ( parsedData['eventType'] === `padUpdate`) {
// Pass on the parsed data
this.onPadUpdate(parsedData);
} // Check if this is a pad Status Update
else if ( parsedData['eventType'] === `statusUpdate`) {
// Pass on the parsed data
this.onStatusUpdate(parsedData);
}
updateStatus(`Established`, `text-success`);
}
}
/**
* Whenever a pad update is trigered, run this function
* @param {Object} The response from the server
*/
onPadUpdate = data => {
// Check that the content is clear
if ( !!data['message']['content'] ) {
// Send over the new content to be updated.
updatePadContent(data['message']['content']);
}
}
onStatusUpdate = data => {
// Check that the content is clear
if ( !!data['message']['currentViewers'] ) {
// Get the amount of viewers reported by the server
const viewerCount = Number(data['message']['currentViewers']);
// Check if this is a valid number
if ( Number.isNaN(viewerCount) ) {
// Looks like this is a malformed message
return console.error(`Malformed Message`, data);
}
// Send over the new content to be updated.
updatePadViewers(viewerCount);
}
}
/**
* Sending a pad update for each keystroke to the server.
* @param {String} msg The new contents of the pad
*/
sendPadUpdate = msg => {
// Get the contents of the pad
const padContents = this.padContents.value;
// Send the data over the webSocket
this.sendMessage(`padUpdate`, {
"content": padContents,
});
updatePadContent(padContents, false);
}
}
/**
* Update the contents of the pad
* @param {String} newContent
*/
function updatePadContent(newContent, textArea = true) {
// Update the textarea
if ( textArea ) {
document.getElementById(`pad-content`).value = newContent;
}
// Update the preview
const prev = document.getElementById(`textarea-preview`);
const shouldScroll = prev.scrollTop >= (prev.scrollHeight - Number(getComputedStyle(prev).height.replace(/px/g, ''))) * 0.98;
prev.innerHTML = escapeHtml(newContent);
prev.classList.remove(`language-undefined`);
prev.classList.forEach( c => {
if ( c.indexOf(`language-`) != -1 ) {
prev.classList.remove(c);
}
})
try { // highlights
hljs.highlightElement(document.getElementById(`textarea-preview`));
} catch ( err ) {
console.err(err);
}
// Check if we should follow the bottom scrolling
if (shouldScroll) {
prev.scrollTop = prev.scrollHeight;
}
}
function updatePadViewers(vc) {
// Get the reference to the viewers count inputElement
/**
* @type {HTMLInputElement}
*/
const viewerCount = document.getElementById(`currentViewers`);
// Get the amount of total viewers
const totalViews = viewerCount.value.split("|")[1].trim();
// Set back the real value
viewerCount.value = `${vc} | ${totalViews}`;
}
function connectSocket() {
// Check if the socket is established
if ( !!!window.socket || window.socket.readyState !== WebSocket.OPEN ) {
updateStatus(`Connecting...`, `text-warning`);
// Connect the socket
window.socket = new PadSocket(padTitle);
}
}
// TODO: Test if this is actually necessary or the DOMContentLoaded event would suffice
// wait for the whole window to load
window.addEventListener(`load`, e => {
connectSocket()
})
// lol
function escapeHtml(html){
const text = document.createTextNode(html);
const p = document.createElement('p');
p.appendChild(text);
const content = p.innerHTML;
p.remove();
return content;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
/*!
* Bootstrap-Dark-5 v1.1.3 (https://vinorodrigues.github.io/bootstrap-dark-5/)
* Copyright 2021 Vino Rodrigues
* Licensed under MIT (https://github.com/vinorodrigues/bootstrap-dark-5/blob/main/LICENSE.md)
*/
"use strict";class DarkMode{constructor(){this._hasGDPRConsent=!1,this.cookieExpiry=365,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",(function(){DarkMode.onDOMContentLoaded()})):DarkMode.onDOMContentLoaded()}get inDarkMode(){return DarkMode.getColorScheme()==DarkMode.VALUE_DARK}set inDarkMode(e){this.setDarkMode(e,!1)}get hasGDPRConsent(){return this._hasGDPRConsent}set hasGDPRConsent(e){if(this._hasGDPRConsent=e,e){const e=DarkMode.readCookie(DarkMode.DATA_KEY);e&&(DarkMode.saveCookie(DarkMode.DATA_KEY,"",-1),localStorage.setItem(DarkMode.DATA_KEY,e))}else{const e=localStorage.getItem(DarkMode.DATA_KEY);e&&(localStorage.removeItem(DarkMode.DATA_KEY),DarkMode.saveCookie(DarkMode.DATA_KEY,e))}}get documentRoot(){return document.getElementsByTagName("html")[0]}static saveCookie(e,o="",t=365){let a="";if(t){const e=new Date;e.setTime(e.getTime()+24*t*60*60*1e3),a="; expires="+e.toUTCString()}document.cookie=e+"="+o+a+"; SameSite=Strict; path=/"}saveValue(e,o,t=this.cookieExpiry){this.hasGDPRConsent?DarkMode.saveCookie(e,o,t):localStorage.setItem(e,o)}static readCookie(e){const o=e+"=",t=document.cookie.split(";");for(let e=0;e<t.length;e++){const a=t[e].trim();if(a.startsWith(o))return a.substring(o.length)}return""}readValue(e){if(this.hasGDPRConsent)return DarkMode.readCookie(e);{const o=localStorage.getItem(e);return o||""}}eraseValue(e){this.hasGDPRConsent?this.saveValue(e,"",-1):localStorage.removeItem(e)}getSavedColorScheme(){const e=this.readValue(DarkMode.DATA_KEY);return e||""}getPreferedColorScheme(){return window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?DarkMode.VALUE_DARK:window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches?DarkMode.VALUE_LIGHT:""}setDarkMode(e,o=!0){const t=document.querySelectorAll("[data-"+DarkMode.DATA_SELECTOR+"]");if(0==t.length)e?(this.documentRoot.classList.remove(DarkMode.CLASS_NAME_LIGHT),this.documentRoot.classList.add(DarkMode.CLASS_NAME_DARK)):(this.documentRoot.classList.remove(DarkMode.CLASS_NAME_DARK),this.documentRoot.classList.add(DarkMode.CLASS_NAME_LIGHT));else for(let o=0;o<t.length;o++)t[o].setAttribute("data-"+DarkMode.DATA_SELECTOR,e?DarkMode.VALUE_DARK:DarkMode.VALUE_LIGHT);o&&this.saveValue(DarkMode.DATA_KEY,e?DarkMode.VALUE_DARK:DarkMode.VALUE_LIGHT)}toggleDarkMode(e=!0){let o;const t=document.querySelector("[data-"+DarkMode.DATA_SELECTOR+"]");o=t?t.getAttribute("data-"+DarkMode.DATA_SELECTOR)==DarkMode.VALUE_DARK:this.documentRoot.classList.contains(DarkMode.CLASS_NAME_DARK),this.setDarkMode(!o,e)}resetDarkMode(){this.eraseValue(DarkMode.DATA_KEY);const e=this.getPreferedColorScheme();if(e)this.setDarkMode(e==DarkMode.VALUE_DARK,!1);else{const e=document.querySelectorAll("[data-"+DarkMode.DATA_SELECTOR+"]");if(0==e.length)this.documentRoot.classList.remove(DarkMode.CLASS_NAME_LIGHT),this.documentRoot.classList.remove(DarkMode.CLASS_NAME_DARK);else for(let o=0;o<e.length;o++)e[o].setAttribute("data-"+DarkMode.DATA_SELECTOR,"")}}static getColorScheme(){const e=document.querySelector("[data-"+DarkMode.DATA_SELECTOR+"]");if(e){const o=e.getAttribute("data-"+DarkMode.DATA_SELECTOR);return o==DarkMode.VALUE_DARK||o==DarkMode.VALUE_LIGHT?o:""}return darkmode.documentRoot.classList.contains(DarkMode.CLASS_NAME_DARK)?DarkMode.VALUE_DARK:darkmode.documentRoot.classList.contains(DarkMode.CLASS_NAME_LIGHT)?DarkMode.VALUE_LIGHT:""}static updatePreferedColorSchemeEvent(){let e=darkmode.getSavedColorScheme();e||(e=darkmode.getPreferedColorScheme(),e&&darkmode.setDarkMode(e==DarkMode.VALUE_DARK,!1))}static onDOMContentLoaded(){let e=darkmode.readValue(DarkMode.DATA_KEY);e||(e=DarkMode.getColorScheme(),e||(e=darkmode.getPreferedColorScheme()));const o=e==DarkMode.VALUE_DARK;darkmode.setDarkMode(o,!1),window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",(function(){DarkMode.updatePreferedColorSchemeEvent()}))}}DarkMode.DATA_KEY="bs.prefers-color-scheme",DarkMode.DATA_SELECTOR="bs-color-scheme",DarkMode.VALUE_LIGHT="light",DarkMode.VALUE_DARK="dark",DarkMode.CLASS_NAME_LIGHT="light",DarkMode.CLASS_NAME_DARK="dark";const darkmode=new DarkMode;

1173
static/vendor/hljs/highlight.min.js vendored Normal file

File diff suppressed because one or more lines are too long

98
static/vendor/hljs/theme.css vendored Normal file
View File

@ -0,0 +1,98 @@
/*!
Theme: a11y-dark
Author: @ericwbailey
Maintainer: @ericwbailey
Based on the Tomorrow Night Eighties theme: https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow-night-eighties.css
*/
.hljs {
background: #2b2b2b;
color: #f8f8f2;
}
/* Comment */
.hljs-comment,
.hljs-quote {
color: #d4d0ab;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #ffa07a;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #f5ab35;
}
/* Yellow */
.hljs-attribute {
color: #ffd700;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #abe338;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #00e0e0;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #dcc6e0;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
@media screen and (-ms-high-contrast: active) {
.hljs-addition,
.hljs-attribute,
.hljs-built_in,
.hljs-bullet,
.hljs-comment,
.hljs-link,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-params,
.hljs-string,
.hljs-symbol,
.hljs-type,
.hljs-quote {
color: highlight;
}
.hljs-keyword,
.hljs-selector-tag {
font-weight: bold;
}
}

File diff suppressed because one or more lines are too long

1
static/vendor/qrcodejs/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
{{ define "inc/footer.html"}} {{ define "inc/footer.html"}}
<script src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"></script> <script src="/static/vendor/bootstrap/darkmode.min.js"></script>
<script src="/static/js/main.js"></script> <script src="/static/js/main.js"></script>
{{ end }} {{ end }}

View File

@ -14,7 +14,7 @@
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css" rel="stylesheet"> <link href="/static/vendor/bootstrap/bootstrap-nightshade.min.css" rel="stylesheet">
<!-- Love https://vinorodrigues.github.io/bootstrap-dark-5/ --> <!-- Love https://vinorodrigues.github.io/bootstrap-dark-5/ -->
<link rel="stylesheet" href="/static/css/main.css"> <link rel="stylesheet" href="/static/css/main.css">
</head> </head>

View File

@ -0,0 +1,42 @@
{{ template "inc/header.html" .}}
<body>
<main id="main-card" class="container rounded mt-5 shadow-sm">
<div class="p-3">
<a href="/" class="logo-container w-100 d-flex mb-4">
<img src="/static/img/logo_transparent.png" alt="Logo" style="max-width: 50%; margin: 0 auto;" class="mx-auto">
</a>
<div class="form-group my-4">
<form class="search-action input-group" method="post" action="/admin/login">
<input autocomplete="off" type="password" class="form-control form-control-lg" name="admin-token" placeholder="Your Admin token" aria-label="Your Admin token" aria-describedby="admin-token-button" id="admin-token">
<button class="btn btn-primary" type="submit" id="admin-token-button">
<svg xmlns="http://www.w3.org/2000/svg" width="24 " height="24 " fill="currentColor" class="bi bi-box-arrow-in-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M6 3.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 6.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-8A1.5 1.5 0 0 0 5 3.5v2a.5.5 0 0 0 1 0v-2z"/>
<path fill-rule="evenodd" d="M11.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H1.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg>
</button>
</form>
<small class="text-muted">Access the admin interface for FreePad, this can only be done through the Admin Token.</small>
</div>
</div>
<footer class="text-muted py-5 border-top text-center">
<p class="mb-1">
FreePad by <a href="https://justkato.me/">©Kato Twofold</a>
</p>
<p class="mb-0">
FreePad is freely available over on our <a href="https://github.com/JustKato/FreePad">GitHub</a>
</p>
</footer>
</main>
{{ template "inc/theme-toggle.html" .}}
</body>
{{ template "inc/footer.html" .}}

View File

@ -0,0 +1,94 @@
{{ template "inc/header.html" .}}
<style>
.pad-instance {
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
}
#pad-list {
max-height: 30rem;
overflow-x: hidden;
overflow-y: auto;
}
.pad-name {
max-width: 30%;
overflow: hidden;
}
</style>
<body>
<main id="main-card" class="container rounded mt-5 shadow-sm">
<div class="p-3">
<a href="/" class="logo-container w-100 d-flex mb-4">
<img src="/static/img/logo_transparent.png" alt="Logo" style="max-width: 50%; margin: 0 auto;" class="mx-auto">
</a>
<div class="form-group my-4 border-top p-3 border">
<div class="pad-instance my-2 border-bottom">
<div class="pad-name col-5">
Pad Name
</div>
<div class="pad-last-view col-1">
Views
</div>
<div class="pad-last-modified col-4">
Create Date
</div>
<div class="col-2">
Actions
</div>
</div>
<div id="pad-list" >
{{ range $indx, $element := .padList }}
<div class="pad-instance my-2">
<div class="pad-name col-5">
<a href="/{{ $element.Name }}">
{{ $element.Name }}
</a>
</div>
<div class="pad-last-view col-1">
{{ $element.Views }}
</div>
<div class="pad-last-modified col-4">
{{ $element.LastModified }}
</div>
<div class="col-2">
<div onclick="doDelete({{ $element.Name }})" class="btn btn-danger">
Delete
</div>
</div>
</div>
{{ end }}
</div>
</div>
</div>
</main>
{{ template "inc/theme-toggle.html" .}}
</body>
<script>
function doDelete(id) {
// Confirm deletion
if ( confirm("Confirm pad deletion?") ) {
// Do delete
window.location.href = `/admin/delete/${id}`;
}
}
</script>
{{ template "inc/footer.html" .}}

View File

@ -46,6 +46,22 @@
just write it in the box above and get to the right page, write anything in just write it in the box above and get to the right page, write anything in
and access the same address on any other device to get your info! and access the same address on any other device to get your info!
</p> </p>
<small>A couple hints:</small>
<p>
Pads take into consideration file extensions, use <code>.json</code>,
<code>.js</code>, <code>.cpp</code>, <code>.txt</code>, etc... to help
parse your type of file
</p>
<p>
The archival feature helps you store information on your local machine! Save your
pads and you can always come back and rewrite them exactly as they have been
</p>
<p>
All pads can be publicly edited, so if you choose some common name
and someone elses accesses the link they can completely remove/edit
what you wrote, not to mention seein that information, so refrain
from sharing important data here.
</p>
</div> </div>
</div> </div>

View File

@ -21,6 +21,8 @@
var padTitle = {{.title }}; var padTitle = {{.title }};
</script> </script>
<link rel="stylesheet" href="/static/vendor/hljs/theme.css">
<body> <body>
<main id="main-card" class="container rounded mt-5 shadow-sm"> <main id="main-card" class="container rounded mt-5 shadow-sm">
@ -33,11 +35,30 @@
</div> </div>
<h2 class="mb-4">{{.title}}</h2> <h2 class="mb-4" id="padTitle">
{{.title}}
</h2>
<div id="pad-content-area">
<div class="btn-sm btn" id="pad-content-toggler" onclick="toggleTextareaPreview()">
<span class="edit-content-text" title="Edit Content">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
</span>
<span class="view-content-text" title="ReadOnly">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eyeglasses" viewBox="0 0 16 16" style="margin-top: 1rem;">
<path d="M4 6a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm2.625.547a3 3 0 0 0-5.584.953H.5a.5.5 0 0 0 0 1h.541A3 3 0 0 0 7 8a1 1 0 0 1 2 0 3 3 0 0 0 5.959.5h.541a.5.5 0 0 0 0-1h-.541a3 3 0 0 0-5.584-.953A1.993 1.993 0 0 0 8 6c-.532 0-1.016.208-1.375.547zM14 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0z"/>
</svg>
</span>
</div>
<pre><code id="textarea-preview" class="form-control hidden">{{.post_content}}</code></pre>
<textarea maxlength="{{.maximumPadSize}}" name="pad-content" id="pad-content" onkeyup="window.socket.sendPadUpdate()"
class="form-control hidden">{{.post_content}}</textarea>
</div>
<textarea maxlength="{{.maximumPadSize}}" name="pad-content" id="pad-content" onchange="sendMyData(this)"
onkeydown="updateStatus(`Not Saved`, `text-warning`); toggleWritingWatch(this)"
class="form-control">{{.post_content}}</textarea>
<div id="pad-status" class="my-4 row"> <div id="pad-status" class="my-4 row">
<div class="col-md-12 col-lg-4 col-xl-4" title="Status"> <div class="col-md-12 col-lg-4 col-xl-4" title="Status">
@ -53,7 +74,7 @@
</div> </div>
</div> </div>
<div class="col-md-12 col-lg-4 col-xl-4 mt-4 mt-lg-0 mt-xl-0" title="Current Viewers"> <div class="col-md-12 col-lg-4 col-xl-4 mt-4 mt-lg-0 mt-xl-0" title="Current Viewers | Total Views">
<div class="input-group"> <div class="input-group">
<span class="input-group-text"> <span class="input-group-text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
@ -66,7 +87,7 @@
</path> </path>
</svg> </svg>
</span> </span>
<input type="text" class="form-control" readonly value="{{.views}}"> <input type="text" class="form-control" readonly value="1 | {{.views}}" id="currentViewers">
</div> </div>
</div> </div>
@ -189,13 +210,15 @@
{{ template "inc/theme-toggle.html" .}} {{ template "inc/theme-toggle.html" .}}
</body> </body>
<script src="/static/js/ws.js"></script>
<script src="/static/js/fileSaver.js"></script> <script src="/static/js/fileSaver.js"></script>
<script src="/static/js/pad.js"></script> <script src="/static/js/pad.js"></script>
<script src="/static/js/pad-scripts.js"></script> <script src="/static/js/pad-scripts.js"></script>
<script src="/static/vendor/hljs/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script> <script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<script src="https://cdn.rawgit.com/davidshimjs/qrcodejs/gh-pages/qrcode.min.js"></script> <script src="/static/vendor/qrcodejs/qrcode.min.js"></script>
<script src="https://unpkg.com/micromodal/dist/micromodal.min.js"></script> <script src="/static/vendor/micromodal/micromodal.min.js"></script>
<script> <script>
window.pad = new Pad({{.title }}, {{.last_modified }}); window.pad = new Pad({{.title }}, {{.last_modified }});