diff --git a/.gitignore b/.gitignore index 92882dc..e6b17a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env .vscode/ +.ash_history/ files/ .cache/ main \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9692be9..cda8681 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,14 +5,14 @@ ARG gid=1000 ARG uid=1000 RUN mkdir -p "$APP_ROOT" \ && addgroup --system sfu -g $gid \ - && adduser -h "$APP_ROOT" --disabled-password --system -u $uid --ingroup sfu sfu + && adduser -h "$APP_ROOT" --disabled-password --system -u $uid --ingroup sfu sfu \ + && apk add curl~=7 WORKDIR "$APP_ROOT" USER sfu:sfu FROM base AS build COPY go.mod . -COPY main.go . -COPY logger.go . +COPY *.go ./ RUN go build \ && rm -r go.mod *.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2a337c --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# sfu + +simple requirementsless, authenticationless file upload server + +## prod version + +WIP + +## dev version + +### using docker + +1. take a look at [docker-compose](docker-compose.yml) and modify the envvars accordingly. the default values **should work** as long as you're ok with a `files/` folder being created and **your user UID and GID are 1000**. if for some reason you need other ids, please add them like this: + +```docker-compose +... + +app: + build: + context: . + target: run_dev + environment: + - SFU_PORT=80 + - SFU_FILES_DIR=./files + - gid=1234 + - uid=1234 +... +``` + +### dockerless + +1. sfu needs two envvars to be set +- `SFU_PORT`: the port to listen on +- `SFU_FILES_DIR`: the directory to store files in +2. run the server +```shell +go build +SFU_PORT=80 SFU_FILES_DIR=./files ./main +``` \ No newline at end of file diff --git a/design/openapi.yml b/design/openapi.yml index 9538001..099b95a 100644 --- a/design/openapi.yml +++ b/design/openapi.yml @@ -7,7 +7,7 @@ servers: - url: http://localhost:8080/api/v1/ description: local server paths: - /: + /files: get: summary: "list all uploaded files" description: "list all uploaded files" @@ -18,11 +18,9 @@ paths: content: text/plain: schema: - type: array - items: - type: string - format: uri - + type: string + format: html + 500: description: "internal server error" content: @@ -74,8 +72,9 @@ paths: text/plain: schema: type: string + default: "internal server error" - /{file_name}: + /files/{file_name}: get: summary: "get file" description: "get file" @@ -135,6 +134,26 @@ paths: schema: type: string default: "file not found" + 500: + description: "internal server error" + content: + text/plain: + schema: + type: string + default: "internal server error" + /health: + get: + summary: "health check" + description: "health check" + operationId: "health_check" + responses: + 200: + description: "health check successful" + content: + text/plain: + schema: + type: string + default: "health check successful" 500: description: "internal server error" content: diff --git a/docker-compose.yml b/docker-compose.yml index 07821e6..a9346a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,18 @@ services: - SFU_FILES_DIR=./files volumes: - ./:/opt/sfu - + healthcheck: + test: "curl http://localhost:$SFU_PORT/health" + interval: 1s + timeout: 1s + retries: 3 + start_period: 1s + deploy: + resources: + limits: + cpus: "0.5" + memory: "200M" + restart: always proxy: image: caddy volumes: @@ -18,3 +29,6 @@ services: - ./design:/usr/share/caddy/www/design ports: - '8080:80' + depends_on: + app: + condition: service_healthy diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..348a913 --- /dev/null +++ b/handlers.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "time" +) + +func downloadFile(w http.ResponseWriter, req *http.Request, fileName string) { + Info.Println(fmt.Sprintf("server: will download %v", fileName)) + file, err := os.Open(fmt.Sprintf("%v/%v", SFU_FILES_DIR, fileName)) + if err != nil { + Error.Println(err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer file.Close() + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v", fileName)) + http.ServeContent(w, req, fileName, time.Now(), file) +} + +func fileExists(fileName string) bool { + _, err := os.Stat(fmt.Sprintf("%v/%v", SFU_FILES_DIR, fileName)) + if err == nil { + return true + } + if os.IsNotExist(err) { + return false + } + return false +} + +func deleteFile(w http.ResponseWriter, req *http.Request, fileName string) { + Info.Println(fmt.Sprintf("server: will delete %v", fileName)) + if !fileExists(fileName) { + Error.Println(fmt.Sprintf("%v does not exist", fileName)) + http.Error(w, "File not found", http.StatusNotFound) + return + } + err := os.Remove(fmt.Sprintf("%v/%v", SFU_FILES_DIR, fileName)) + if err != nil { + Error.Println(err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("file deleted")) +} + +func uploadFile(w http.ResponseWriter, req *http.Request) { + file, fileHeader, err := req.FormFile("file") + if err != nil { + Error.Println(err) + } + defer file.Close() + fileExists := fileExists(fileHeader.Filename) + forceOverwrite := req.FormValue("force") + if fileExists && forceOverwrite != "true" { + Error.Println(fmt.Sprintf("file %v already exists", fileHeader.Filename)) + http.Error(w, "File already exists", http.StatusConflict) + return + } + if !fileExists || (forceOverwrite == "true" && fileExists) { + Info.Println(fmt.Sprintf("server: will upload %v", fileHeader.Filename)) + out, err := os.Create(fmt.Sprintf("%v/%v", SFU_FILES_DIR, fileHeader.Filename)) + if err != nil { + Error.Println(err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer out.Close() + _, err = io.Copy(out, file) + if err != nil { + Error.Println(err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + w.Write([]byte("file uploaded")) + return + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return +} + +func listFiles(w http.ResponseWriter, req *http.Request) { + Info.Println(fmt.Sprintf("server: will list uploaded files on %v", SFU_FILES_DIR)) + files, err := ioutil.ReadDir(SFU_FILES_DIR) + if err != nil { + Warning.Println(fmt.Sprintf("%v does not exist", SFU_FILES_DIR)) + Info.Println(fmt.Sprintf("will create %v", SFU_FILES_DIR)) + _ = os.Mkdir(SFU_FILES_DIR, os.ModePerm) + } + fmt.Fprint(w, "
    ") + for _, f := range files { + + fmt.Fprintf(w, "
  1. %v
  2. \n", f.Name(), f.Name()) + } + fmt.Fprint(w, "
") +} diff --git a/main.go b/main.go index 3db1565..b673228 100644 --- a/main.go +++ b/main.go @@ -2,44 +2,17 @@ package main import ( "fmt" - "io" - "io/ioutil" "net/http" "os" + "regexp" + "strings" ) -var SFU_FILES_DIR string = get_envvar_or_fatal("SFU_FILES_DIR") -var SFU_PORT string = get_envvar_or_fatal("SFU_PORT") +var SFU_FILES_DIR string = getEnvvar("SFU_FILES_DIR") +var SFU_PORT string = getEnvvar("SFU_PORT") -func upload_file(w http.ResponseWriter, req *http.Request) { - file, fileHeader, err := req.FormFile("file") - if err != nil { - Error.Println(err) - } - defer file.Close() - - f, err := os.OpenFile(fmt.Sprintf("%v/%v", SFU_FILES_DIR, fileHeader.Filename), os.O_WRONLY|os.O_CREATE, 0666) - defer f.Close() - io.Copy(f, file) - w.WriteHeader(http.StatusCreated) -} - -func list_uploaded_files(w http.ResponseWriter, req *http.Request) { - Info.Println(fmt.Sprintf("server: will list uploaded files on %v", SFU_FILES_DIR)) - files, err := ioutil.ReadDir(SFU_FILES_DIR) - if err != nil { - Warning.Println(fmt.Sprintf("%v does not exist", SFU_FILES_DIR)) - Info.Println(fmt.Sprintf("will create %v", SFU_FILES_DIR)) - _ = os.Mkdir(SFU_FILES_DIR, os.ModePerm) - } - fmt.Fprint(w, "
    ") - for _, f := range files { - fmt.Fprintf(w, "
  1. %v
  2. \n", f.Name()) - } - fmt.Fprint(w, "
") -} - -func get_envvar_or_fatal(envvar_name string) string { +func getEnvvar(envvar_name string) string { + Info.Printf("getting envvar %v", envvar_name) envvar_value, isSet := os.LookupEnv(envvar_name) if !isSet { Error.Println(fmt.Sprintf("%v is not set", envvar_name)) @@ -48,31 +21,74 @@ func get_envvar_or_fatal(envvar_name string) string { return envvar_value } -func main() { - http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { - path := req.URL.Path - Info.Println(fmt.Sprintf("received %v on %v", req.Method, path)) - if path != "/" { - err_msg := fmt.Sprintf("path %v does not exist", path) - http.Error(w, err_msg, http.StatusNotFound) - Warning.Println(err_msg) - Error.Println(fmt.Sprintf("will return %v", http.StatusNotFound)) - return +func emptyArray(s []string) []string { + var r []string + for _, str := range s { + if str != "" { + r = append(r, str) } - switch req.Method { + } + return r +} + +func routeFiles(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + Info.Println(fmt.Sprintf("received %v on %v", r.Method, path)) + paths := emptyArray(strings.Split(path, "/")) + Info.Println(fmt.Sprintf("paths %v", paths)) + switch len(paths) { + case 1: + switch r.Method { case http.MethodGet: - list_uploaded_files(w, req) + listFiles(w, r) case http.MethodPost: - upload_file(w, req) + uploadFile(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - Warning.Println(fmt.Sprintf("%v not allowed", req.Method)) + Warning.Println(fmt.Sprintf("%v not allowed", r.Method)) Error.Println(fmt.Sprintf("will return %v", http.StatusMethodNotAllowed)) } - }) + return + case 2: + switch r.Method { + case http.MethodGet: + downloadFile(w, r, paths[1]) + case http.MethodDelete: + deleteFile(w, r, paths[1]) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + Warning.Println(fmt.Sprintf("%v not allowed", r.Method)) + Error.Println(fmt.Sprintf("will return %v", http.StatusMethodNotAllowed)) + } + default: + http.Error(w, "Not found", http.StatusNotFound) + } + +} + +func route(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case path == "/": + fmt.Fprintf(w, "Hello, world!") + case path == "/health": + Info.Println("health check OK") + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + case regexp.MustCompile("^/files").MatchString(path): + Info.Println("received request for files, will call files router") + routeFiles(w, r) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/", route) port := fmt.Sprintf(":%v", SFU_PORT) Info.Println(fmt.Sprintf("running SFU on port %v", port)) - err := http.ListenAndServe(port, nil) + err := http.ListenAndServe(port, mux) if err != nil { Error.Println(fmt.Sprintf("%v port may not be available", port)) os.Exit(1)