commit c1f9cb3dcc1fe18c9f4452f29b17bca319656270 Author: matu6968 Date: Sun Nov 10 22:47:39 2024 +0100 init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..182f859 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d1e016 --- /dev/null +++ b/README.md @@ -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. + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51355f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module main.go + +go 1.23.1 + +require github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -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= diff --git a/init-scripts/app-market-server b/init-scripts/app-market-server new file mode 100644 index 0000000..36fc896 --- /dev/null +++ b/init-scripts/app-market-server @@ -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 +} diff --git a/init-scripts/app-market-server.service b/init-scripts/app-market-server.service new file mode 100755 index 0000000..717f297 --- /dev/null +++ b/init-scripts/app-market-server.service @@ -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 diff --git a/main.go b/main.go new file mode 100644 index 0000000..9a90e39 --- /dev/null +++ b/main.go @@ -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)) +} +