This commit is contained in:
matu6968 2024-11-10 22:47:39 +01:00
commit c1f9cb3dcc
7 changed files with 472 additions and 0 deletions

16
LICENSE Normal file
View File

@ -0,0 +1,16 @@
Copyright 2024 matu6968
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the “Software”), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

92
README.md Normal file
View File

@ -0,0 +1,92 @@
# WebDesk 3rd party App Market server
This allows you to host custom (known as 3rd party) App Market repositories of WebDesk applications.
## Features
- Listing currently uploaded apps (/apps endpoint)
- Showing app source code (under /uploads/app_name/index.js)
- Uploading new apps (/apps endpoint with Bearer token)
- Editing app info (/editapp endpoint with Bearer token)
- Delete apps (/delete endpoint with Bearer token)
## Prerequisites
- Go (1.23.1 or later, older will work with go.mod changes to the version)
## Installation
1. Clone the repository:
```
git clone https://git.fluffy.pw/matu6968/webdesk-app-market-server
```
2. Go to the project directory:
```
cd webdesk-app-market-server
```
3. Build the binary and install web renderer dependencies:
```
go build -o app-market-server
```
4. Execture the binary:
```
./app-market-server
```
# Configuration
In the .env file this is the only thing you can set
```
PORT=8080
TOKEN=bearer-token-here # The program will automatically make a new token if not found so do not bother putting your own one
```
## Autostart with systemd or OpenRC
You can autostart the web server using systemd or OpenRC (init scripts are in the init-scripts folder)
To use it, edit the script accordingly (edit username on what user it is going to run and the path to the binary on where it will run from)
## for systemd edit the following lines:
```
; Don't forget to change the value here!
; There is no reason to run this program as root, just use your username
User=examplename # change this to your username
WorkingDirectory=/path/to/binary # change this to the path where the binary resides
```
### and to add it as a service:
```
sudo cp /path/to/cloned/repo/init-scripts/app-market-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable app-market-server.service
sudo systemctl start app-market-server.service
```
## for OpenRC edit the following lines:
```
command="bash -c cd ~/webdesk-app-market-server && ./app-market-server" # if you have put the eprintclone binary somewhere else change this line
# Don't forget to change the value here!
# There is no reason to run this program as root, just use your username
command_user="userexample" # change this to your usernames
```
### and to add it as a service:
```
sudo cp /path/to/cloned/repo/init-scripts/app-market-server /etc/init.d/
sudo rc-update add app-market-server
sudo rc-service app-market-server start
```
### How does this work
To use the API look at the [wiki](https://git.fluffy.pw/matu6968/webdesk-app-market-server/wiki)
# !IMPORTANT!
You will need a directory where your program is with read/write rights otherwaise actions like deleting apps will fail.

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module main.go
go 1.23.1
require github.com/joho/godotenv v1.5.1

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

View File

@ -0,0 +1,15 @@
#!/sbin/openrc-run
name=app-market-server
description="3rd party WebDesk App Market server"
command="bash -c cd ~/webdesk-app-market-server && ./app-market-server" # if you have put the eprintclone binary somewhere else change this line
# Don't forget to change the value here!
# There is no reason to run this program as root, just use your username
command_user="userexample" # change this to your username
pidfile="/run/${RC_SVCNAME}.pid"
depend() {
need net
}

View File

@ -0,0 +1,29 @@
[Unit]
Description=3rd party WebDesk App Market server
Requires=network-online.target
After=network-online.target
[Service]
Type=simple
; Don't forget to change the value here!
; There is no reason to run this program as root, just use your username
User=examplename # change this to your username
WorkingDirectory=/path/to/binary # change this to the path where the binary resides
ExecStart=./app-market-server
; Always restart the script
Restart=always
; cf. https://www.darkcoding.net/software/the-joy-of-systemd/
; /usr, /boot and /etc are read-only
ProtectSystem=full
; /tmp is isolated from all other processes
PrivateTmp=false
; Don't allow process to raise privileges (e.g. disable suid)
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

313
main.go Normal file
View File

@ -0,0 +1,313 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"sync"
"strings"
"github.com/joho/godotenv"
)
type App struct {
Name string `json:"name"`
Version string `json:"ver"`
ID string `json:"appid"`
Info string `json:"info"`
Developer string `json:"pub"`
Path string `json:"path"`
}
var (
apps []App
appsFilePath = "apps.json"
uploadDir = "uploads"
bearerToken string
mutex sync.Mutex
)
func loadApps() error {
data, err := ioutil.ReadFile(appsFilePath)
if err != nil {
return err
}
return json.Unmarshal(data, &apps)
}
func saveApps() error {
data, err := json.MarshalIndent(apps, "", " ")
if err != nil {
return err
}
return ioutil.WriteFile(appsFilePath, data, 0644)
}
func generateToken() (string, error) {
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
return hex.EncodeToString(tokenBytes), nil
}
func loadOrGenerateToken() error {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found. Generating a new token.")
}
bearerToken = os.Getenv("TOKEN")
if bearerToken == "" {
var err error
bearerToken, err = generateToken()
if err != nil {
return fmt.Errorf("failed to generate token: %v", err)
}
err = saveTokenToEnv(bearerToken)
if err != nil {
return fmt.Errorf("failed to save token to .env: %v", err)
}
log.Printf("Generated new token: %s\n", bearerToken)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
return nil
}
func saveTokenToEnv(token string) error {
return ioutil.WriteFile(".env", []byte("TOKEN="+token), 0644)
}
func listAppsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
mutex.Lock()
defer mutex.Unlock()
json.NewEncoder(w).Encode(apps)
}
func saveUploadedFile(customPath, fileName string, file multipart.File) (string, error) {
appDir := filepath.Join(uploadDir, customPath)
if err := os.MkdirAll(appDir, 0755); err != nil {
return "", err
}
filePath := filepath.Join(appDir, fileName)
dst, err := os.Create(filePath)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
return "", err
}
return fmt.Sprintf("/%s/%s/%s", uploadDir, customPath, fileName), nil
}
func uploadAppHandler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "Bearer "+bearerToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
metadata := r.FormValue("metadata")
var newApp App
if err := json.Unmarshal([]byte(metadata), &newApp); err != nil {
http.Error(w, "Invalid metadata JSON", http.StatusBadRequest)
return
}
file, handler, err := r.FormFile("file")
if err != nil {
http.Error(w, "File upload error", http.StatusBadRequest)
return
}
defer file.Close()
customPath := r.FormValue("customPath")
if customPath == "" {
customPath = strings.ReplaceAll(newApp.Name, " ", "_")
}
filePath, err := saveUploadedFile(customPath, handler.Filename, file)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
newApp.Path = filePath
mutex.Lock()
apps = append(apps, newApp)
if err := saveApps(); err != nil {
mutex.Unlock()
http.Error(w, "Failed to save app data", http.StatusInternalServerError)
return
}
mutex.Unlock()
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newApp)
}
func deleteAppHandler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "Bearer "+bearerToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
appID := r.URL.Query().Get("id")
if appID == "" {
http.Error(w, "App ID is required", http.StatusBadRequest)
return
}
mutex.Lock()
defer mutex.Unlock()
for i, app := range apps {
if app.ID == appID {
// Remove the app's file if it exists
if err := os.Remove(filepath.Join(".", app.Path)); err != nil {
log.Printf("Failed to delete file: %v\n", err)
}
// Remove the app from the list
apps = append(apps[:i], apps[i+1:]...)
if err := saveApps(); err != nil {
http.Error(w, "Failed to save apps", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("App deleted successfully"))
return
}
}
http.Error(w, "App not found", http.StatusNotFound)
}
func editAppHandler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "Bearer "+bearerToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
appID := r.FormValue("id")
if appID == "" {
http.Error(w, "App ID is required", http.StatusBadRequest)
return
}
var updatedApp *App
mutex.Lock()
for i := range apps {
if apps[i].ID == appID {
updatedApp = &apps[i]
break
}
}
mutex.Unlock()
if updatedApp == nil {
http.Error(w, "App not found", http.StatusNotFound)
return
}
if name := r.FormValue("name"); name != "" {
updatedApp.Name = name
}
if info := r.FormValue("info"); info != "" {
updatedApp.Info = info
}
// Check if a new file is uploaded
file, handler, err := r.FormFile("file")
if err == nil {
defer file.Close()
customPath := r.FormValue("customPath")
if customPath == "" {
customPath = strings.ReplaceAll(updatedApp.Name, " ", "_")
}
filePath, err := saveUploadedFile(customPath, handler.Filename, file)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Remove the old file
os.Remove(filepath.Join(".", updatedApp.Path))
updatedApp.Path = filePath
}
mutex.Lock()
defer mutex.Unlock()
if err := saveApps(); err != nil {
http.Error(w, "Failed to save app data", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(updatedApp)
}
func main() {
if err := loadOrGenerateToken(); err != nil {
log.Fatalf("Error loading or generating token: %v", err)
}
if err := loadApps(); err != nil {
if os.IsNotExist(err) {
log.Println("apps.json not found. Starting with an empty app list.")
} else {
log.Fatalf("Failed to load apps.json: %v", err)
}
}
http.Handle("/uploads/", http.StripPrefix("/uploads", http.FileServer(http.Dir(uploadDir))))
http.HandleFunc("/apps", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listAppsHandler(w, r)
case http.MethodPost:
uploadAppHandler(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/delete", deleteAppHandler)
http.HandleFunc("/editapp", editAppHandler)
port := os.Getenv("PORT")
fmt.Printf("Server starting on port %s\n", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}