├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── auth ├── router.go └── session.go ├── config └── config.go ├── db └── db.go ├── docker-compose-example.yaml ├── docker-compose-mysql-example.yaml ├── faces ├── faces.go └── types.go ├── go.mod ├── go.sum ├── handlers ├── album.go ├── asset.go ├── backup.go ├── bucket.go ├── call.go ├── faces.go ├── group.go ├── handlers.go ├── message.go ├── moment.go ├── tag.go ├── upload.go ├── user.go └── websocket.go ├── locations └── nominatim.go ├── main.go ├── models ├── album.go ├── album_asset.go ├── album_contributor.go ├── album_share.go ├── asset.go ├── asset_test.go ├── face.go ├── favourite_asset.go ├── grant.go ├── group.go ├── group_message.go ├── group_user.go ├── init.go ├── location.go ├── person.go ├── place.go ├── upload_request.go ├── user.go └── video_call.go ├── processing ├── detectfaces.go ├── location.go ├── metadata.go ├── metadata_test.go ├── processing.go ├── processing_task.go ├── thumb.go └── video.go ├── push ├── album.go └── push.go ├── static ├── cam-off.png ├── cam.png ├── close.png ├── mic2-off.png └── mic2.png ├── storage ├── bucket.go ├── disk.go ├── s3.go └── storage.go ├── templates ├── album_view.tmpl ├── call_view.tmpl └── upload_files.tmpl ├── utils ├── cache_router.go ├── error_log_middleware.go └── utils.go ├── web ├── album.go ├── call.go └── upload.go └── webrtc ├── room.go └── turn.go /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out the repo 11 | uses: actions/checkout@v3 12 | 13 | - name: Login to Docker Hub 14 | uses: docker/login-action@v2 15 | with: 16 | username: ${{ secrets.DOCKERHUB_USERNAME }} 17 | password: ${{ secrets.DOCKERHUB_TOKEN }} 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v2 21 | 22 | - name: Build and push 23 | uses: docker/build-push-action@v4 24 | with: 25 | context: . 26 | platforms: linux/amd64,linux/arm64 27 | file: ./Dockerfile 28 | push: true 29 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/circled-server:latest,${{ secrets.DOCKERHUB_USERNAME }}/circled-server:${{ github.ref_name }} 30 | build-args: VERSION=${{ github.ref_name }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | circled-server.tar 2 | server 3 | asset-data 4 | mysql-data 5 | circled-data 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine3.21 2 | RUN apk add dlib dlib-dev --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ 3 | RUN apk add blas blas-dev cblas lapack lapack-dev libjpeg-turbo-dev cmake make gcc libc-dev g++ unzip libx11-dev pkgconf jpeg jpeg-dev libpng libpng-dev mailcap 4 | 5 | COPY go.mod /go/src/circled-server/ 6 | COPY go.sum /go/src/circled-server/ 7 | WORKDIR /go/src/circled-server/ 8 | RUN go mod download 9 | # Precompile dependencies to speed up local builds 10 | RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE -w -O3" GOOS=linux go build github.com/Kagami/go-face 11 | RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE -w -O3" GOOS=linux go build github.com/mattn/go-sqlite3 12 | COPY . /go/src/circled-server 13 | RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE -w -O3" GOOS=linux go build -a -installsuffix cgo -o circled-server . 14 | 15 | # Final output image 16 | FROM alpine:3.21 17 | RUN apk add dlib --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing/ 18 | RUN apk --no-cache add ca-certificates exiftool tzdata blas cblas lapack libjpeg-turbo libstdc++ libgcc ffmpeg 19 | WORKDIR /opt/circled 20 | # Use 68 landmarks model instead of 5 landmarks model 21 | ADD https://github.com/ageitgey/face_recognition_models/raw/master/face_recognition_models/models/shape_predictor_68_face_landmarks.dat ./models/shape_predictor_5_face_landmarks.dat 22 | ADD https://github.com/ageitgey/face_recognition_models/raw/master/face_recognition_models/models/dlib_face_recognition_resnet_model_v1.dat ./models/ 23 | ADD https://github.com/ageitgey/face_recognition_models/raw/master/face_recognition_models/models/mmod_human_face_detector.dat ./models/ 24 | COPY --from=0 /etc/mime.types /etc/mime.types 25 | COPY --from=0 /go/src/circled-server/circled-server . 26 | COPY --from=0 /go/src/circled-server/templates ./templates 27 | COPY --from=0 /go/src/circled-server/static ./static 28 | ENTRYPOINT ["./circled-server"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nikolay Dimitrov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Circled.me Community Server 2 | This project aims to help people easily backup and share photos, videos, albums on their own server and to enable communication, including audio/video calls and chats. And do all this by keeping everything private. Focusing on performance, low footprint and ease of implementation and use. The application is not dependant on any other service if you use the default SQLite DB engine. 3 | 4 | Having the ability to host everything a community needs to be able to communicate and exchange photos, ideas, etc, is the main focus here. 5 | I strongly believe in local/focused communities and sharing with the community, but at the same time - keeping everything private, within the community. 6 | In my personal case, I share mostly photos with my family and close friends, and also use the video call functionality to talk to them. 7 | 8 | Logo is madebytow.com 9 | 10 | ## Mobile app 11 | The **circled.me** mobile app **works with multiple accounts and servers**. For example, you can have your family server and account, and your gaming/running/reading comunities' accounts on the same app and being able to interact with all of them at the same time. It is currently the only way to interface with the server. Go to https://circled.me to download it or go to the source repo here: https://github.com/circled-me/app 12 | 13 | 14 | 15 | ___ 16 | 17 | ⚠️ **NOTE: Please note that this project is still in development and could introduce breaking changes.** 18 | 19 | ⚠️ **WARNING: Do not use this as your main/only backup solution.** 20 | 21 | ___ 22 | 23 | 24 | ## Main features: 25 | - Fast response times and low CPU and very low memory footprint 26 | - iOS and Android photo backup (using the circled.me app available on the AppStore and Google Play) 27 | - Supports either locally mounted disks or 28 | - S3-compatible Services - this allows different users to use their own S3 bucket on the same server 29 | - Push notifications for new Album photos, etc 30 | - Video/Audio Calls using the mobile app OR any browser 31 | - Face detection and tagging 32 | - Albums 33 | - Adding local server contributors and viewers 34 | - Sharing albums with anyone with a "secret" link 35 | - Chat with push notifications 36 | - Filtering photos by tagged person, year, month, location, etc 37 | - Moments - automatically grouping photos by time and location 38 | - Reverse geocoding for all assets 39 | - Automatic video conversion to web-compatible H.264 format 40 | 41 | 42 | ## Compiling and Running the server 43 | To compile the server you will need go 1.21 or above and simply build it within the cloned repository: `CGO_ENABLED=1 go build`. 44 | 45 | The easiest way to try and run the server is to use the latest image available on Docker Hub, see example docker-compose file below. 46 | The latest version of the server uses SQLite as default DB engine. 47 | 48 | ```bash 49 | docker-compose -f docker-compose-example.yaml up 50 | ``` 51 | 52 | Now you can use the app and connect to your server at `http://:8080` and create your first (admin) user. 53 | 54 | Current configuration environment variables: 55 | - `SQLITE_FILE` - location of the SQLite file. If non-existent, a new DB file will be created with the given name. Note that MySQL below takes precedence (if both configured) 56 | - `MYSQL_DSN` - see example or refer to https://github.com/go-sql-driver/mysql#dsn-data-source-name 57 | - `BIND_ADDRESS` - IP and port to bind to (incompatible with `TLS_DOMAINS`). This is useful if your server is, say, behind reverse proxy 58 | - `TLS_DOMAINS` - a list of comma-separated domain names. This uses the Let's Encrypt Gin implementation (https://github.com/gin-gonic/autotls) 59 | - `DEBUG_MODE` - currently defaults to `yes` 60 | - `DEFAULT_BUCKET_DIR` - a directory that will be used as default bucket if no other buckets exist (i.e. the first time you run the server) 61 | - `DEFAULT_ASSET_PATH_PATTERN` - the default path pattern to create subdirectories and file names based on asset info. Defaults to `//` 62 | - `PUSH_SERVER` - the push server URL. Defaults to `https://push.circled.me` 63 | - `FACE_DETECT` - enable/disable face detection. Defaults to `yes` 64 | - `FACE_DETECT_CNN` - use Convolutional Neural Network for face detection (as opposed to HOG). Much slower, but more accurate at different angles. Defaults to `no` 65 | - `FACE_MAX_DISTANCE_SQ` - squared distance between faces to consider them similar. Defaults to `0.11` 66 | - `TURN_SERVER_IP` - if configured, Pion TURN server would be started locally and this value used to advertise ourselves. Should be your public IP. Defaults to empty string 67 | - `TURN_SERVER_PORT` - Defaults to port '3478' (UDP) 68 | - `TURN_TRAFFIC_MIN_PORT` and `TURN_TRAFFIC_MAX_PORT` - Advertise-able UDP port range for TURN traffic. Those ports need to be open on your public IP (and forwarded to the circled.me server instance). Defaults to 49152-65535 69 | 70 | ## docker-compose example 71 | ```yaml 72 | version: '2' 73 | services: 74 | circled-server: 75 | image: gubble/circled-server:latest 76 | restart: always 77 | ports: 78 | - "8080:8080" 79 | environment: 80 | SQLITE_FILE: "/mnt/data1/circled.db" 81 | BIND_ADDRESS: "0.0.0.0:8080" 82 | DEFAULT_BUCKET_DIR: "/mnt/data1" 83 | DEFAULT_ASSET_PATH_PATTERN: "//" 84 | volumes: 85 | - ./circled-data:/mnt/data1 86 | ``` 87 | The project includes an example docker-compose file with MySQL configuration. 88 | -------------------------------------------------------------------------------- /auth/router.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | "server/models" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // user is authenticated and posseses the required permissions 11 | type HandlerFunc func(c *gin.Context, user *models.User) 12 | 13 | // Router is a wrapper class that adds auth checks and user pre-loading 14 | type Router struct { 15 | Base *gin.Engine 16 | } 17 | 18 | func (cr *Router) baseExec(c *gin.Context, handler HandlerFunc, required []models.Permission) { 19 | session := LoadSession(c) 20 | user := session.User() 21 | if user.ID == 0 || !user.HasPermissions(required) { 22 | c.JSON(http.StatusUnauthorized, gin.H{"error": "access denied"}) 23 | return 24 | } 25 | handler(c, &user) 26 | } 27 | 28 | func (cr *Router) POST(path string, handler HandlerFunc, required ...models.Permission) { 29 | cr.Base.POST(path, func(c *gin.Context) { 30 | cr.baseExec(c, handler, required) 31 | }) 32 | } 33 | 34 | func (cr *Router) GET(path string, handler HandlerFunc, required ...models.Permission) { 35 | cr.Base.GET(path, func(c *gin.Context) { 36 | cr.baseExec(c, handler, required) 37 | }) 38 | } 39 | 40 | func (cr *Router) PUT(path string, handler HandlerFunc, required ...models.Permission) { 41 | cr.Base.PUT(path, func(c *gin.Context) { 42 | cr.baseExec(c, handler, required) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /auth/session.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "server/db" 5 | "server/models" 6 | 7 | "github.com/gin-contrib/sessions" 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | const userIdKey = "id" 12 | 13 | type Session struct { 14 | sessions.Session 15 | } 16 | 17 | func LoadSession(c *gin.Context) *Session { 18 | return &Session{ 19 | Session: sessions.Default(c), 20 | } 21 | } 22 | 23 | func (s *Session) LogoutUser() { 24 | s.Delete(userIdKey) 25 | s.Clear() 26 | s.Options(sessions.Options{Path: "/", MaxAge: -1}) 27 | s.Save() 28 | } 29 | 30 | func (s *Session) User() (user models.User) { 31 | id := s.Get(userIdKey) 32 | if id == nil { 33 | return 34 | } 35 | user.ID = id.(uint64) 36 | if db.Instance.Preload("Grants").Preload("Bucket").First(&user).Error != nil { 37 | user.ID = 0 38 | } 39 | return 40 | } 41 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | TLS_DOMAINS = "" // e.g. "example.com,example2.com" 11 | DEFAULT_ASSET_PATH_PATTERN = "//" // also available: , 12 | PUSH_SERVER = "https://push.circled.me" 13 | MYSQL_DSN = "" // MySQL will be used if this is set 14 | SQLITE_FILE = "" // SQLite will be used if MYSQL_DSN is not configured and this is set 15 | BIND_ADDRESS = "0.0.0.0:8080" 16 | TMP_DIR = "/tmp" // Used for temporary video conversion, etc (in case of S3 bucket) 17 | DEFAULT_BUCKET_DIR = "" // Used for creating initial bucket 18 | DEBUG_MODE = true 19 | FACE_DETECT = true // Enable/disable face detection 20 | FACE_DETECT_CNN = false // Use Convolutional Neural Network for face detection (as opposed to HOG). Much slower, supposedly more accurate at different angles 21 | FACE_MAX_DISTANCE_SQ = 0.11 // Squared distance between faces to consider them similar 22 | // TURN server support is better be enabled if you are planning to use the video/audio call functionalities. 23 | // By default a public STUN server would be added, but in cases where NAT firewall rules are too strict (symmetric NATs, etc), a TURN server is needed to relay the traffic 24 | TURN_SERVER_IP = "" // If configured, Pion TURN server would be started locally and this value used to advertise ourselves. Should be your public IP. Defaults to empty string. 25 | TURN_SERVER_PORT = 3478 // Defaults to UDP port 3478 26 | TURN_TRAFFIC_MIN_PORT = 49152 27 | TURN_TRAFFIC_MAX_PORT = 65535 // Advertise-able UDP port range for TURN traffic. Those ports need to be open on your public IP (and forwarded to the circled.me server instance). Defaults to 49152-65535 28 | ) 29 | 30 | func init() { 31 | readEnvString("TLS_DOMAINS", &TLS_DOMAINS) 32 | readEnvString("PUSH_SERVER", &PUSH_SERVER) 33 | readEnvString("MYSQL_DSN", &MYSQL_DSN) 34 | readEnvString("SQLITE_FILE", &SQLITE_FILE) 35 | readEnvString("BIND_ADDRESS", &BIND_ADDRESS) 36 | readEnvString("TMP_DIR", &TMP_DIR) 37 | readEnvString("DEFAULT_BUCKET_DIR", &DEFAULT_BUCKET_DIR) 38 | readEnvString("DEFAULT_ASSET_PATH_PATTERN", &DEFAULT_ASSET_PATH_PATTERN) 39 | readEnvBool("DEBUG_MODE", &DEBUG_MODE) 40 | readEnvBool("FACE_DETECT", &FACE_DETECT) 41 | readEnvBool("FACE_DETECT_CNN", &FACE_DETECT_CNN) 42 | readEnvFloat("FACE_MAX_DISTANCE_SQ", &FACE_MAX_DISTANCE_SQ) 43 | readEnvString("TURN_SERVER_IP", &TURN_SERVER_IP) 44 | readEnvInt("TURN_SERVER_PORT", &TURN_SERVER_PORT) 45 | readEnvInt("TURN_TRAFFIC_MIN_PORT", &TURN_TRAFFIC_MIN_PORT) 46 | readEnvInt("TURN_TRAFFIC_MAX_PORT", &TURN_TRAFFIC_MAX_PORT) 47 | } 48 | 49 | func readEnvString(name string, value *string) { 50 | v := os.Getenv(name) 51 | if v == "" { 52 | return 53 | } 54 | *value = v 55 | } 56 | 57 | func readEnvBool(name string, value *bool) { 58 | v := strings.ToLower(os.Getenv(name)) 59 | if v == "true" || v == "1" || v == "yes" || v == "on" { 60 | *value = true 61 | } else if v == "false" || v == "0" || v == "no" || v == "off" { 62 | *value = false 63 | } 64 | } 65 | 66 | func readEnvFloat(name string, value *float64) { 67 | v := os.Getenv(name) 68 | if v == "" { 69 | return 70 | } 71 | f, err := strconv.ParseFloat(v, 64) 72 | if err != nil { 73 | return 74 | } 75 | *value = f 76 | } 77 | 78 | func readEnvInt(name string, value *int) { 79 | v := os.Getenv(name) 80 | if v == "" { 81 | return 82 | } 83 | f, err := strconv.Atoi(v) 84 | if err != nil { 85 | return 86 | } 87 | *value = f 88 | } 89 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "log" 5 | "server/config" 6 | 7 | "gorm.io/driver/mysql" 8 | "gorm.io/driver/sqlite" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | var ( 13 | Instance *gorm.DB 14 | TimestampFunc = "" 15 | CreatedDateFunc = "" 16 | ) 17 | 18 | func Init() { 19 | var db *gorm.DB 20 | var err error 21 | if config.MYSQL_DSN != "" { 22 | // MySQL setup 23 | db, err = gorm.Open(mysql.Open(config.MYSQL_DSN), &gorm.Config{ 24 | PrepareStmt: true, 25 | }) 26 | if err != nil || db == nil { 27 | log.Fatalf("MySQL DB error: %v", err) 28 | } 29 | TimestampFunc = "unix_timestamp()" 30 | CreatedDateFunc = "date(from_unixtime(created_at))" 31 | } else if config.SQLITE_FILE != "" { 32 | // Sqlite setup 33 | db, err = gorm.Open(sqlite.Open(config.SQLITE_FILE+"?_foreign_keys=on"), &gorm.Config{}) 34 | if err != nil || db == nil { 35 | log.Fatalf("SQLite DB error: %v", err) 36 | } 37 | TimestampFunc = "strftime('%s', 'now')" 38 | CreatedDateFunc = "date(created_at, 'unixepoch')" 39 | } else { 40 | log.Fatal("No database configuration found") 41 | } 42 | Instance = db 43 | } 44 | -------------------------------------------------------------------------------- /docker-compose-example.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | circled-server: 4 | image: gubble/circled-server:latest 5 | restart: always 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | SQLITE_FILE: "/mnt/data1/circled.db" 10 | BIND_ADDRESS: "0.0.0.0:8080" 11 | DEFAULT_BUCKET_DIR: "/mnt/data1" 12 | DEFAULT_ASSET_PATH_PATTERN: "//" 13 | volumes: 14 | - ./circled-data:/mnt/data1 -------------------------------------------------------------------------------- /docker-compose-mysql-example.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | circled-server: 4 | image: gubble/circled-server:latest 5 | # build: 6 | # dockerfile: Dockerfile 7 | restart: always 8 | depends_on: 9 | mysql: 10 | condition: service_healthy 11 | ports: 12 | - "8080:8080" 13 | environment: 14 | MYSQL_DSN: "root:@tcp(mysql:3306)/circled?charset=utf8mb4&parseTime=True&loc=Local" 15 | BIND_ADDRESS: 0.0.0.0:8080 16 | DEFAULT_BUCKET_DIR: "/mnt/data1" 17 | DEFAULT_ASSET_PATH_PATTERN: "//" 18 | volumes: 19 | - ./asset-data:/mnt/data1 20 | 21 | mysql: 22 | image: mysql:5.7 23 | command: --default-authentication-plugin=mysql_native_password 24 | restart: always 25 | volumes: 26 | - ./mysql-data:/var/lib/mysql 27 | environment: 28 | MYSQL_DATABASE: circled 29 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 30 | MYSQL_ROOT_HOST: "%" 31 | healthcheck: 32 | test: mysqladmin ping --silent 33 | start_period: 5s 34 | interval: 3s 35 | timeout: 5s 36 | retries: 20 -------------------------------------------------------------------------------- /faces/faces.go: -------------------------------------------------------------------------------- 1 | package faces 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | "server/config" 7 | 8 | "github.com/Kagami/go-face" 9 | ) 10 | 11 | var ( 12 | modelsDir = filepath.Join(".", "models") 13 | rec *face.Recognizer 14 | ) 15 | 16 | func init() { 17 | if !config.FACE_DETECT { 18 | log.Println("Face detection is disabled") 19 | return 20 | } 21 | log.Println("Loading face recognition models...") 22 | // Init the recognizer. 23 | var err error 24 | rec, err = face.NewRecognizer(modelsDir) 25 | if err != nil { 26 | log.Fatalf("Can't init face recognizer: %v", err) 27 | } 28 | } 29 | 30 | func Detect(imgPath string) ([]face.Face, error) { 31 | log.Printf("Detecting faces in %s", imgPath) 32 | // Recognize faces on that image. 33 | if !config.FACE_DETECT_CNN { 34 | // HOG (Histogram of Oriented Gradients) based detection 35 | return rec.RecognizeFile(imgPath) 36 | } 37 | // CNN (Convolutional Neural Network) based detection 38 | return rec.RecognizeFileCNN(imgPath) 39 | } 40 | -------------------------------------------------------------------------------- /faces/types.go: -------------------------------------------------------------------------------- 1 | package faces 2 | 3 | import "encoding/json" 4 | 5 | const ( 6 | IndexTop = 0 7 | IndexRight = 1 8 | IndexBottom = 2 9 | IndexLeft = 3 10 | ) 11 | 12 | type ( 13 | FaceBoundaries [4]int 14 | FaceBoundariesList []FaceBoundaries 15 | FaceEncoding [128]float64 16 | FaceEncodingList []FaceEncoding 17 | FaceDetectionResult struct { 18 | Locations FaceBoundariesList `json:"locations"` 19 | Encodings FaceEncodingList `json:"encodings"` 20 | } 21 | ) 22 | 23 | func toFacesResult(data []byte) (result FaceDetectionResult, err error) { 24 | // Parse the string 25 | err = json.Unmarshal(data, &result) 26 | return result, err 27 | } 28 | 29 | func (l *FaceBoundaries) ToJSONString() string { 30 | data, _ := json.Marshal(l) 31 | return string(data) 32 | } 33 | 34 | func (e *FaceEncoding) ToJSONString() string { 35 | data, _ := json.Marshal(e) 36 | return string(data) 37 | } 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module server 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e 7 | github.com/aws/aws-sdk-go v1.45.25 8 | github.com/gin-contrib/cors v1.6.0 9 | github.com/gin-contrib/gzip v0.0.6 10 | github.com/gin-contrib/sessions v0.0.5 11 | github.com/gin-gonic/autotls v0.0.5 12 | github.com/gin-gonic/gin v1.9.1 13 | github.com/go-sql-driver/mysql v1.7.1 14 | github.com/google/uuid v1.6.0 15 | github.com/gorilla/websocket v1.5.0 16 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 17 | github.com/orcaman/concurrent-map/v2 v2.0.1 18 | github.com/pion/turn/v2 v2.1.6 19 | github.com/zsefvlol/timezonemapper v1.0.0 20 | golang.org/x/sys v0.32.0 21 | gorm.io/driver/mysql v1.5.2 22 | gorm.io/driver/sqlite v1.4.1 23 | gorm.io/gorm v1.25.5 24 | ) 25 | 26 | require ( 27 | github.com/bytedance/sonic v1.11.2 // indirect 28 | github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect 29 | github.com/chenzhuoyu/iasm v0.9.1 // indirect 30 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 31 | github.com/gin-contrib/sse v0.1.0 // indirect 32 | github.com/go-playground/locales v0.14.1 // indirect 33 | github.com/go-playground/universal-translator v0.18.1 // indirect 34 | github.com/go-playground/validator/v10 v10.19.0 // indirect 35 | github.com/goccy/go-json v0.10.2 // indirect 36 | github.com/gorilla/context v1.1.1 // indirect 37 | github.com/gorilla/securecookie v1.1.1 // indirect 38 | github.com/gorilla/sessions v1.2.1 // indirect 39 | github.com/jinzhu/inflection v1.0.0 // indirect 40 | github.com/jinzhu/now v1.1.5 // indirect 41 | github.com/jmespath/go-jmespath v0.4.0 // indirect 42 | github.com/json-iterator/go v1.1.12 // indirect 43 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 44 | github.com/leodido/go-urn v1.4.0 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect 47 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 48 | github.com/modern-go/reflect2 v1.0.2 // indirect 49 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 50 | github.com/pion/dtls/v2 v2.2.7 // indirect 51 | github.com/pion/logging v0.2.2 // indirect 52 | github.com/pion/randutil v0.1.0 // indirect 53 | github.com/pion/stun v0.6.1 // indirect 54 | github.com/pion/transport/v2 v2.2.1 // indirect 55 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 56 | github.com/ugorji/go/codec v1.2.12 // indirect 57 | github.com/wader/gormstore/v2 v2.0.3 // indirect 58 | golang.org/x/arch v0.7.0 // indirect 59 | golang.org/x/crypto v0.37.0 // indirect 60 | golang.org/x/net v0.39.0 // indirect 61 | golang.org/x/sync v0.13.0 // indirect 62 | golang.org/x/text v0.24.0 // indirect 63 | google.golang.org/protobuf v1.33.0 // indirect 64 | gopkg.in/yaml.v3 v3.0.1 // indirect 65 | // github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /handlers/asset.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "log" 7 | "net/http" 8 | "server/db" 9 | "server/models" 10 | "server/storage" 11 | "server/utils" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | _ "image/jpeg" 17 | 18 | "github.com/gin-gonic/gin" 19 | "github.com/gin-gonic/gin/binding" 20 | ) 21 | 22 | type AssetFetchRequest struct { 23 | ID uint64 `form:"id" binding:"required"` 24 | Thumb uint `form:"thumb"` 25 | Download uint `form:"download"` 26 | Size uint `form:"size"` 27 | } 28 | 29 | type AssetInfo struct { 30 | ID uint64 `json:"id"` 31 | Type uint `json:"type"` 32 | Owner uint64 `json:"owner"` 33 | Name string `json:"name"` 34 | Location *string `json:"location"` 35 | DID string `json:"did"` // DeviceID 36 | Created uint64 `json:"created"` 37 | GpsLat *float64 `json:"gps_lat"` 38 | GpsLong *float64 `json:"gps_long"` 39 | Size uint64 `json:"size"` 40 | MimeType string `json:"mime_type"` 41 | Favourite bool `json:"favourite"` 42 | } 43 | 44 | const ( 45 | // created_at field is adjusted with time_offset so the time can be shown "as UTC" 46 | AssetsSelectClause = "assets.id, assets.name, assets.user_id, assets.created_at+ifnull(time_offset,0), assets.remote_id, assets.mime_type, assets.gps_lat, assets.gps_long, locations.display, assets.size, assets.mime_type, favourite_assets.asset_id is not null as f" 47 | LeftJoinForLocations = "left join locations ON locations.gps_lat = round(assets.gps_lat*10000-0.5)/10000.0 AND locations.gps_long = round(assets.gps_long*10000-0.5)/10000.0" 48 | ) 49 | 50 | type AssetDeleteRequest struct { 51 | IDs []uint64 `json:"ids" binding:"required"` 52 | } 53 | 54 | type AssetFavouriteRequest struct { 55 | ID uint64 `json:"id" binding:"required"` 56 | AlbumAssetID uint64 `json:"album_asset_id"` 57 | } 58 | 59 | // TODO: Move to before save in Asset 60 | func GetTypeFrom(mimeType string) uint { 61 | if strings.HasPrefix(mimeType, "image/") { 62 | return models.AssetTypeImage 63 | } 64 | if strings.HasPrefix(mimeType, "video/") { 65 | return models.AssetTypeVideo 66 | } 67 | return models.AssetTypeOther 68 | } 69 | 70 | func LoadAssetsFromRows(c *gin.Context, rows *sql.Rows) *[]AssetInfo { 71 | result := []AssetInfo{} 72 | mimeType := "" 73 | for rows.Next() { 74 | assetInfo := AssetInfo{} 75 | if err := rows.Scan(&assetInfo.ID, &assetInfo.Name, &assetInfo.Owner, &assetInfo.Created, &assetInfo.DID, &mimeType, 76 | &assetInfo.GpsLat, &assetInfo.GpsLong, &assetInfo.Location, &assetInfo.Size, &assetInfo.MimeType, &assetInfo.Favourite); err != nil { 77 | 78 | log.Printf("DB error: %v", err) 79 | c.JSON(http.StatusInternalServerError, DBError2Response) 80 | return nil 81 | } 82 | assetInfo.Type = GetTypeFrom(mimeType) 83 | result = append(result, assetInfo) 84 | } 85 | return &result 86 | } 87 | 88 | func AssetList(c *gin.Context, user *models.User) { 89 | fr := AssetsForFaceRequest{} 90 | _ = c.ShouldBindQuery(&fr) 91 | 92 | // Modified depends on deleted assets as well, that's why the where condition is different 93 | tx := db.Instance. 94 | Table("assets"). 95 | Select("max(updated_at)"). 96 | Where("user_id=? AND size>0 AND thumb_size>0", user.ID) 97 | if fr.FaceID == 0 && c.Query("reload") != "1" && isNotModified(c, tx) { 98 | return 99 | } 100 | // TODO: For big sets maybe dynamically load asset info individually? 101 | tmp := db.Instance. 102 | Table("assets"). 103 | Select(AssetsSelectClause). 104 | Joins("left join favourite_assets on favourite_assets.asset_id = assets.id"). 105 | Joins(LeftJoinForLocations) 106 | if fr.FaceID > 0 { 107 | // Find assets with faces similar to the given face or with the same person already assigned 108 | tmp = tmp.Joins("join (select distinct t2.asset_id from faces t1 join faces t2 where t1.id=? and (t1.person_id = t2.person_id OR "+models.FacesVectorDistance+" <= ?)) f on f.asset_id = assets.id", fr.FaceID, fr.Threshold) 109 | } 110 | rows, err := tmp. 111 | Where("assets.user_id=? and assets.deleted=0 and assets.size>0 and assets.thumb_size>0", user.ID).Order("assets.created_at DESC").Rows() 112 | if err != nil { 113 | c.JSON(http.StatusInternalServerError, DBError1Response) 114 | return 115 | } 116 | defer rows.Close() 117 | result := LoadAssetsFromRows(c, rows) 118 | if result == nil { 119 | return 120 | } 121 | c.JSON(http.StatusOK, result) 122 | } 123 | 124 | func AssetFetch(c *gin.Context, user *models.User) { 125 | RealAssetFetch(c, user.ID) 126 | } 127 | 128 | func checkAlbumAccess(c *gin.Context, checkUser, assetID uint64) bool { 129 | // Check if we have access via any shared album or if any of those albums is ours 130 | var sum int64 131 | result := db.Instance.Raw("select sum(ifnull(album_contributors.user_id, ifnull(albums.user_id, 0))) "+ 132 | "from album_assets "+ 133 | "left join album_contributors on (album_contributors.album_id = album_assets.album_id and album_contributors.user_id = ?) "+ 134 | "left join albums on (albums.id = album_assets.album_id and albums.user_id = ?) "+ 135 | "where asset_id=?", checkUser, checkUser, assetID).Scan(&sum) 136 | if result.Error != nil { 137 | c.JSON(http.StatusInternalServerError, DBError1Response) 138 | return false 139 | } 140 | if sum == 0 { 141 | c.JSON(http.StatusUnauthorized, NopeResponse) 142 | return false 143 | } 144 | return true 145 | } 146 | 147 | func RealAssetFetch(c *gin.Context, checkUser uint64) { 148 | r := AssetFetchRequest{} 149 | err := c.ShouldBindQuery(&r) 150 | if err != nil { 151 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 152 | return 153 | } 154 | asset := models.Asset{ 155 | ID: r.ID, 156 | } 157 | db.Instance.Joins("Bucket").First(&asset) 158 | if checkUser > 0 && asset.UserID != checkUser { 159 | if !checkAlbumAccess(c, checkUser, r.ID) { 160 | return 161 | } 162 | } 163 | storage := storage.StorageFrom(&asset.Bucket) 164 | if storage == nil { 165 | panic("Storage is nil") 166 | } 167 | if asset.Bucket.IsS3() { 168 | isThumb := false 169 | if r.Thumb == 1 && asset.ThumbSize > 0 { 170 | isThumb = true 171 | } 172 | // Redirect to the S3 location 173 | url, expires := asset.GetS3DownloadURL(isThumb) 174 | maxAge := expires - time.Now().Unix() 175 | c.Header("cache-control", "private, max-age="+strconv.FormatInt(maxAge, 10)) 176 | c.Redirect(302, url) 177 | return 178 | } 179 | c.Header("cache-control", "private, max-age=604800") 180 | if r.Thumb == 1 && asset.ThumbSize > 0 { 181 | c.Header("content-type", "image/jpeg") 182 | if r.Size == 0 { 183 | // Default big (1280) thumb size 184 | _, err = storage.Load(asset.ThumbPath, c.Writer) 185 | } else { 186 | // Custom size 187 | var buf bytes.Buffer 188 | if _, err = storage.Load(asset.ThumbPath, &buf); err == nil { 189 | var imageThumbInfo utils.ImageThumbConverted 190 | imageThumbInfo, err = utils.CreateThumb(r.Size, &buf, c.Writer) 191 | c.Header("content-length", strconv.FormatInt(imageThumbInfo.ThumbSize, 10)) 192 | } 193 | } 194 | } else { 195 | // Original 196 | c.Header("content-type", asset.MimeType) 197 | if r.Download == 1 { 198 | c.Header("content-disposition", "attachment; filename=\""+asset.Name+"\"") 199 | } 200 | // Handles Byte-ranges too 201 | storage.Serve(asset.Path, c.Request, c.Writer) 202 | return 203 | } 204 | // Handle errors 205 | if err != nil { 206 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 207 | } 208 | } 209 | 210 | func AssetDelete(c *gin.Context, user *models.User) { 211 | r := AssetDeleteRequest{} 212 | err := c.ShouldBindWith(&r, binding.JSON) 213 | if err != nil { 214 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 215 | return 216 | } 217 | failed := []uint64{} 218 | for _, id := range r.IDs { 219 | asset := models.Asset{ 220 | ID: id, 221 | } 222 | db.Instance.Joins("Bucket").First(&asset) 223 | if asset.ID != id || asset.UserID != user.ID { 224 | failed = append(failed, id) 225 | log.Printf("Asset: %d, auth error", id) 226 | continue 227 | } 228 | // Delete asset record and rely on cascaded deletes 229 | if db.Instance.Exec("delete from assets where id=?", id).Error != nil { 230 | failed = append(failed, id) 231 | log.Printf("Asset: %d, delete error %s", id, err) 232 | continue 233 | } 234 | // Re-insert with same RemoteID to stop backing up the same asset 235 | db.Instance.Exec("insert into assets (user_id, remote_id, updated_at, deleted) values (?, ?, ?, 1)", asset.UserID, asset.RemoteID, time.Now().Unix()) 236 | 237 | storage := storage.StorageFrom(&asset.Bucket) 238 | if storage == nil { 239 | log.Printf("Asset: %d, error: storage is nil", id) 240 | failed = append(failed, id) 241 | continue 242 | } 243 | // Finally delete the files 244 | if err = storage.Delete(asset.ThumbPath); err != nil { 245 | log.Printf("Asset: %d, thumb delete error: %s", id, err.Error()) 246 | } 247 | if err = storage.Delete(asset.Path); err != nil { 248 | log.Printf("Asset: %d, delete error: %s", id, err.Error()) 249 | } 250 | // Remote (S3) as well 251 | if err = storage.DeleteRemoteFile(asset.ThumbPath); err != nil { 252 | log.Printf("Remote Asset: %d, thumb delete error: %s", id, err.Error()) 253 | } 254 | if err = storage.DeleteRemoteFile(asset.Path); err != nil { 255 | log.Printf("Remote Asset: %d, delete error: %s", id, err.Error()) 256 | } 257 | } 258 | // Handle errors 259 | if len(failed) > 0 { 260 | c.JSON(http.StatusInternalServerError, MultiResponse{"Some assets cannot be deleted", failed}) 261 | return 262 | } 263 | c.JSON(http.StatusOK, OKMultiResponse) 264 | } 265 | 266 | func AssetFavourite(c *gin.Context, user *models.User) { 267 | r := AssetFavouriteRequest{} 268 | err := c.ShouldBindJSON(&r) 269 | if err != nil { 270 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 271 | return 272 | } 273 | asset := models.Asset{ID: r.ID} 274 | db.Instance.First(&asset) 275 | if asset.ID != r.ID { 276 | c.JSON(http.StatusUnauthorized, NopeResponse) 277 | return 278 | } 279 | if r.AlbumAssetID == 0 || asset.UserID == user.ID { 280 | r.AlbumAssetID = 0 281 | // This must be our own asset 282 | if asset.ID != r.ID || asset.UserID != user.ID { 283 | c.JSON(http.StatusUnauthorized, Nope2Response) 284 | return 285 | } 286 | } else { 287 | // We should have access to this album 288 | albumAsset := models.AlbumAsset{ID: r.AlbumAssetID} 289 | db.Instance.First(&albumAsset) 290 | if albumAsset.ID != r.AlbumAssetID || albumAsset.AssetID != r.ID { 291 | c.JSON(http.StatusUnauthorized, Nope3Response) 292 | return 293 | } 294 | if !checkAlbumAccess(c, user.ID, r.ID) { 295 | return 296 | } 297 | } 298 | // All checks done! Phew... 299 | fav := models.FavouriteAsset{ 300 | UserID: user.ID, 301 | AssetID: r.ID, 302 | AlbumAssetID: nil, 303 | } 304 | if r.AlbumAssetID > 0 { 305 | fav.AlbumAssetID = &r.AlbumAssetID 306 | } 307 | if db.Instance.Create(&fav).Error != nil { 308 | c.JSON(http.StatusInternalServerError, DBError1Response) 309 | return 310 | } 311 | c.JSON(http.StatusOK, OKResponse) 312 | } 313 | 314 | func AssetUnfavourite(c *gin.Context, user *models.User) { 315 | r := AssetFavouriteRequest{} 316 | err := c.ShouldBindJSON(&r) 317 | if err != nil { 318 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 319 | return 320 | } 321 | fav := models.FavouriteAsset{} 322 | err = db.Instance.First(&fav, "user_id=? AND asset_id=?", user.ID, r.ID).Error 323 | if err != nil || fav.UserID != user.ID || fav.AssetID != r.ID { 324 | c.JSON(http.StatusUnauthorized, NopeResponse) 325 | return 326 | } 327 | if db.Instance.Delete(&fav).Error != nil { 328 | c.JSON(http.StatusInternalServerError, DBError3Response) 329 | return 330 | } 331 | c.JSON(http.StatusOK, OKResponse) 332 | } 333 | -------------------------------------------------------------------------------- /handlers/backup.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "io" 7 | "mime" 8 | "net/http" 9 | "path/filepath" 10 | "server/db" 11 | "server/models" 12 | "server/storage" 13 | "strings" 14 | 15 | "github.com/gin-gonic/gin" 16 | ) 17 | 18 | const ( 19 | backUpCheckSize = 900 20 | ) 21 | 22 | type BackupRequest struct { 23 | RemoteID string `json:"id" binding:"required"` 24 | Name string `json:"name" binding:"required"` 25 | MimeType string `json:"mimetype"` 26 | Lat *float64 `json:"lat"` 27 | Long *float64 `json:"long"` 28 | Created int64 `json:"created"` 29 | Favourite bool `json:"favourite"` 30 | Width uint16 `json:"width"` 31 | Height uint16 `json:"height"` 32 | Duration uint32 `json:"duration"` 33 | TimeOffset *int `json:"time_offset"` 34 | } 35 | 36 | type BackupConfirmation struct { 37 | ID uint64 `form:"id" binding:"required"` // Local DB ID 38 | Size int64 `form:"size" binding:"required"` 39 | ThumbSize int64 `form:"thumb_size" binding:""` 40 | } 41 | 42 | type BackupUploadRequest struct { 43 | ID uint64 `form:"id" binding:"required"` // Local DB ID 44 | Thumb bool `form:"thumb"` 45 | } 46 | 47 | type BackupCheckRequest struct { 48 | IDs []string `binding:"required"` 49 | } 50 | 51 | type NewMetadataResponse struct { 52 | ID uint64 `json:"id"` 53 | URI string `json:"uri"` 54 | Thumb string `json:"thumb"` 55 | MimeType string `json:"mime_type"` 56 | } 57 | 58 | type BackupAssetResponse struct { 59 | Error string `json:"error"` 60 | ID uint64 `json:"id"` 61 | } 62 | 63 | func BackupMetaData(c *gin.Context, user *models.User) { 64 | var r BackupRequest 65 | err := c.ShouldBindJSON(&r) 66 | if err != nil { 67 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 68 | return 69 | } 70 | _ = NewMetadata(c, user, &r) 71 | } 72 | 73 | func BackupConfirm(c *gin.Context, user *models.User) { 74 | var r BackupConfirmation 75 | err := c.ShouldBindQuery(&r) 76 | if err != nil { 77 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 78 | return 79 | } 80 | asset := models.Asset{ 81 | ID: r.ID, 82 | Size: r.Size, 83 | ThumbSize: r.ThumbSize, 84 | } 85 | err = db.Instance.Where("id=? AND user_id=?", r.ID, user.ID).Updates(&asset).Error 86 | if err != nil { 87 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 88 | return 89 | } 90 | } 91 | 92 | func NewMetadata(c *gin.Context, user *models.User, r *BackupRequest) *models.Asset { 93 | if user.BucketID == nil { 94 | panic("Bucket is nil") 95 | } 96 | if user.HasNoRemainingQuota() { 97 | c.JSON(http.StatusForbidden, Response{"Quota exceeded"}) 98 | return nil 99 | } 100 | asset := models.Asset{ 101 | UserID: user.ID, 102 | User: *user, 103 | RemoteID: r.RemoteID, 104 | Name: r.Name, 105 | GroupID: nil, 106 | BucketID: *user.BucketID, 107 | GpsLat: r.Lat, 108 | GpsLong: r.Long, 109 | CreatedAt: r.Created, 110 | Favourite: r.Favourite, 111 | Width: r.Width, 112 | Height: r.Height, 113 | Duration: r.Duration, 114 | TimeOffset: r.TimeOffset, 115 | } 116 | if r.MimeType != "" { 117 | asset.MimeType = r.MimeType 118 | } else { 119 | // Guess the mime type from the extension 120 | asset.MimeType = mime.TypeByExtension(filepath.Ext(asset.Name)) 121 | } 122 | // For now, only allow image and video 123 | if asset.MimeType != "image/jpeg" && 124 | asset.MimeType != "image/png" && 125 | asset.MimeType != "image/gif" && 126 | asset.MimeType != "image/heic" && // TODO: which one to remain? 127 | asset.MimeType != "image/heif" && 128 | !strings.HasPrefix(asset.MimeType, "video/") { 129 | 130 | c.JSON(http.StatusForbidden, Response{"this file type is not allowed"}) 131 | return nil 132 | } 133 | 134 | result := db.Instance.Create(&asset) 135 | if result.Error != nil { 136 | // Try loading the asset by RemoteID, maybe it exists and we should overwrite it 137 | result = db.Instance.First(&asset, "remote_id = ?", r.RemoteID) 138 | if result.Error != nil { 139 | // Now give up... 140 | c.JSON(http.StatusInternalServerError, DBError1Response) 141 | return nil 142 | } 143 | } 144 | if db.Instance.Preload("Bucket").First(&asset).Error != nil { 145 | c.JSON(http.StatusInternalServerError, DBError2Response) 146 | return nil 147 | } 148 | if asset.Favourite { 149 | fav := models.FavouriteAsset{ 150 | UserID: user.ID, 151 | AssetID: asset.ID, 152 | AlbumAssetID: nil, 153 | } 154 | _ = db.Instance.Create(&fav) 155 | } 156 | c.JSON(http.StatusOK, NewMetadataResponse{ 157 | ID: asset.ID, 158 | URI: asset.CreateUploadURI(false, ""), 159 | Thumb: asset.CreateUploadURI(true, ""), 160 | MimeType: asset.MimeType, 161 | }) 162 | // Save as Paths are updated 163 | if db.Instance.Save(&asset).Error != nil { 164 | c.JSON(http.StatusInternalServerError, DBError3Response) 165 | return nil 166 | } 167 | return &asset 168 | } 169 | 170 | func BackupUpload(c *gin.Context, user *models.User) { 171 | BackupLocalAsset(user.ID, c) 172 | } 173 | 174 | func BackupLocalAsset(userID uint64, c *gin.Context) { 175 | var r BackupUploadRequest 176 | err := c.ShouldBindQuery(&r) 177 | if err != nil { 178 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 179 | return 180 | } 181 | asset := models.Asset{} 182 | result := db.Instance.Joins("Bucket").Where("user_id = ? AND assets.id = ?", userID, r.ID).Find(&asset) 183 | if result.Error != nil { 184 | c.JSON(http.StatusInternalServerError, DBError1Response) 185 | return 186 | } 187 | if asset.ID != r.ID { 188 | c.JSON(http.StatusForbidden, NopeResponse) 189 | return 190 | } 191 | storage := storage.StorageFrom(&asset.Bucket) 192 | if storage == nil { 193 | panic("Storage is nil") 194 | } 195 | thumbContent := bytes.Buffer{} 196 | reader := io.TeeReader(c.Request.Body, &thumbContent) 197 | size, err := storage.Save(asset.GetPathOrThumb(r.Thumb), reader) 198 | if err != nil { 199 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 200 | return 201 | } 202 | if r.Thumb { 203 | asset.ThumbSize = size 204 | thumb, _, err := image.Decode(&thumbContent) 205 | if err != nil { 206 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 207 | return 208 | } 209 | asset.ThumbWidth = uint16(thumb.Bounds().Dx()) 210 | asset.ThumbHeight = uint16(thumb.Bounds().Dy()) 211 | } else { 212 | asset.Size = size 213 | } 214 | // Re-save asset as we have new .Size, .ThumbWidth, .ThumbHeight 215 | db.Instance.Updates(&asset) 216 | c.JSON(http.StatusOK, BackupAssetResponse{"", asset.ID}) 217 | } 218 | 219 | // BackupCheck returns the ids of all assets that were already uploaded 220 | func BackupCheck(c *gin.Context, user *models.User) { 221 | var r BackupCheckRequest 222 | err := c.ShouldBindJSON(&r) 223 | if err != nil { 224 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 225 | return 226 | } 227 | result := []string{} 228 | var ids []string 229 | // Check in batches as SQLite has a limit of 999 variables 230 | for current := 0; current < len(r.IDs); current += backUpCheckSize { 231 | if current+backUpCheckSize > len(r.IDs) { 232 | ids = r.IDs[current:] 233 | } else { 234 | ids = r.IDs[current : current+backUpCheckSize] 235 | } 236 | rows, err := db.Instance.Table("assets").Select("remote_id"). 237 | Where("user_id = ? AND remote_id IN (?) AND (thumb_size>0 OR (mime_type NOT LIKE 'image/%' AND mime_type NOT LIKE 'video/%'))", user.ID, ids).Rows() 238 | 239 | if err != nil { 240 | c.JSON(http.StatusInternalServerError, DBError1Response) 241 | return 242 | } 243 | var remoteID string 244 | for rows.Next() { 245 | if err = rows.Scan(&remoteID); err != nil { 246 | c.JSON(http.StatusInternalServerError, DBError2Response) 247 | rows.Close() 248 | return 249 | } 250 | result = append(result, remoteID) 251 | } 252 | rows.Close() 253 | } 254 | c.JSON(http.StatusOK, result) 255 | } 256 | -------------------------------------------------------------------------------- /handlers/bucket.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "server/config" 7 | "server/db" 8 | "server/models" 9 | "server/storage" 10 | "strings" 11 | 12 | "github.com/gin-gonic/gin" 13 | "github.com/gin-gonic/gin/binding" 14 | ) 15 | 16 | func hasWriteAccess(bucket *storage.Bucket) error { 17 | storage := storage.NewStorage(bucket) 18 | testPath := "tmp/path" 19 | _, err := storage.Save(testPath, strings.NewReader("some-content")) 20 | if err != nil { 21 | log.Printf("Cannot save to bucket: %+v", bucket) 22 | return err 23 | } 24 | err = storage.UpdateRemoteFile(testPath, "text/plain") 25 | if err != nil { 26 | log.Printf("Cannot update bucket: %+v", bucket) 27 | return err 28 | } 29 | err = storage.Delete(testPath) 30 | if err != nil { 31 | log.Printf("Cannot delete: %+v", bucket) 32 | return err 33 | } 34 | err = storage.DeleteRemoteFile(testPath) 35 | if err != nil { 36 | log.Printf("Cannot delete remote object from bucket: %+v", bucket) 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | func cleanupPath(in *storage.Bucket) { 43 | for strings.Contains(in.Path, "..") { 44 | in.Path = strings.ReplaceAll(in.Path, "..", "") 45 | } 46 | for strings.Contains(in.Path, "//") { 47 | in.Path = strings.ReplaceAll(in.Path, "//", "/") 48 | } 49 | } 50 | 51 | func BucketSave(c *gin.Context, user *models.User) { 52 | bucket := storage.Bucket{} 53 | err := c.ShouldBindWith(&bucket, binding.JSON) 54 | if err != nil { 55 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 56 | return 57 | } 58 | cleanupPath(&bucket) 59 | 60 | if bucket.AssetPathPattern == "" { 61 | bucket.AssetPathPattern = config.DEFAULT_ASSET_PATH_PATTERN 62 | } 63 | if bucket.Name == "" { 64 | c.JSON(http.StatusBadRequest, Response{"Empty bucket name"}) 65 | return 66 | } 67 | if bucket.StorageType == storage.StorageTypeFile { 68 | if bucket.Path == "" { 69 | c.JSON(http.StatusBadRequest, Response{"Empty bucket path"}) 70 | return 71 | } 72 | if bucket.Path[0] != '/' { 73 | c.JSON(http.StatusBadRequest, Response{"Path must be absolute and start with / (slash)"}) 74 | return 75 | } 76 | } else if bucket.StorageType == storage.StorageTypeS3 { 77 | if bucket.S3Key == "" || bucket.S3Secret == "" { 78 | c.JSON(http.StatusBadRequest, Response{"'S3 Key' and 'S3 Secret' must be provided"}) 79 | return 80 | } 81 | if bucket.Region == "" { 82 | bucket.Region = "us-east-1" 83 | } 84 | } else { 85 | c.JSON(http.StatusBadRequest, Response{"'type' must be one of 'file' or 's3'"}) 86 | return 87 | } 88 | if err := hasWriteAccess(&bucket); err != nil { 89 | c.JSON(http.StatusForbidden, Response{"No write access to bucket: " + err.Error()}) 90 | return 91 | } 92 | if err = bucket.CanSave(); err != nil { 93 | c.JSON(http.StatusForbidden, Response{err.Error()}) 94 | return 95 | } 96 | if bucket.ID == 0 { 97 | err = db.Instance.Create(&bucket).Error 98 | } else { 99 | err = db.Instance.Updates(&bucket).Error 100 | } 101 | if err != nil { 102 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 103 | return 104 | } 105 | // Re-initialize storage 106 | storage.Init() 107 | c.JSON(http.StatusOK, OKResponse) 108 | } 109 | 110 | func BucketList(c *gin.Context, user *models.User) { 111 | buckets := []storage.Bucket{} 112 | result := db.Instance.Find(&buckets) 113 | if result.Error != nil { 114 | c.JSON(http.StatusInternalServerError, DBError1Response) 115 | return 116 | } 117 | c.JSON(http.StatusOK, buckets) 118 | } 119 | -------------------------------------------------------------------------------- /handlers/call.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "server/auth" 8 | "server/db" 9 | "server/models" 10 | "server/push" 11 | "server/utils" 12 | "server/webrtc" 13 | "strconv" 14 | 15 | "github.com/gin-gonic/gin" 16 | "github.com/google/uuid" 17 | ) 18 | 19 | func UserCallLink(c *gin.Context, user *models.User) { 20 | if !user.HasPermission(models.PermissionAdmin) && !user.HasPermission(models.PermissionCanCreateGroups) { 21 | c.JSON(http.StatusUnauthorized, NopeResponse) 22 | return 23 | } 24 | vc, err := models.VideoCallForUser(user.ID) 25 | if err != nil { 26 | c.JSON(http.StatusInternalServerError, NopeResponse) 27 | return 28 | } 29 | if c.Query("reset") == "1" { 30 | newID := utils.Rand8BytesToBase62() 31 | if err = db.Instance.Exec("update video_calls set id = ? where id = ?", newID, vc.ID).Error; err != nil { 32 | c.JSON(http.StatusInternalServerError, NopeResponse) 33 | return 34 | } 35 | vc.ID = newID 36 | } 37 | c.JSON(http.StatusOK, gin.H{"path": "/call/" + vc.ID}) 38 | } 39 | 40 | func GroupCallLink(c *gin.Context, user *models.User) { 41 | // Load group and check if user is a member 42 | groupID, _ := strconv.ParseUint(c.Query("id"), 10, 64) 43 | groupUser := models.GroupUser{ 44 | GroupID: groupID, 45 | UserID: user.ID, 46 | } 47 | result := db.Instance.First(&groupUser) 48 | if result.Error != nil { 49 | c.JSON(http.StatusInternalServerError, DBError1Response) 50 | return 51 | } 52 | 53 | vc, err := models.VideoCallForGroup(user.ID, groupID) 54 | if err != nil { 55 | c.JSON(http.StatusInternalServerError, NopeResponse) 56 | return 57 | } 58 | c.JSON(http.StatusOK, gin.H{"path": "/call/" + vc.ID}) 59 | } 60 | 61 | func sendCallNotificationTo(users map[uint64]string, from *models.User, callURL string) { 62 | log.Printf("Sending call notification to %v, url: %s\n", users, callURL) 63 | pushTokens := make([]string, 0, len(users)) 64 | for userID, pushToken := range users { 65 | if userID == from.ID { 66 | continue 67 | } 68 | pushTokens = append(pushTokens, pushToken) 69 | } 70 | if len(pushTokens) == 0 { 71 | return 72 | } 73 | callerName := from.Name 74 | if from.ID == 0 || from.Name == "" { 75 | callerName = "Web User" 76 | } 77 | uuid := uuid.New() 78 | notification := &push.Notification{ 79 | Type: push.NotificationTypeCall, 80 | Title: "Incoming call", 81 | Body: callerName + " is calling you", 82 | Data: map[string]string{ 83 | "id": uuid.String(), 84 | "caller_name": callerName, 85 | "caller_id": callURL, 86 | "video": "1", 87 | }, 88 | } 89 | if err := notification.SendTo(pushTokens); err != nil { 90 | log.Printf("Failed to send call notification to %v, error: %v", pushTokens, err) 91 | } 92 | } 93 | 94 | func CallWebSocket(c *gin.Context) { 95 | stringID := c.Param("id") 96 | vc, err := models.VideoCallByID(stringID) 97 | if err != nil { 98 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) 99 | return 100 | } 101 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) 102 | if err != nil { 103 | log.Print("upgrade error:", err) 104 | return 105 | } 106 | defer conn.Close() 107 | 108 | // Load the user session by setting the token cookie manually from the query 109 | c.Request.Header.Add("Cookie", "token="+c.Query("token")) 110 | session := auth.LoadSession(c) 111 | user := session.User() 112 | log.Printf("User %d is trying to connect to WS %s", user.ID, vc.ID) 113 | 114 | room, isNewRoom := webrtc.GetRoom(stringID) 115 | // Wait for the client to send their ID 116 | _, id, err := conn.ReadMessage() 117 | if err != nil { 118 | log.Println(err) 119 | return 120 | } 121 | client, isNewClient, numClients := room.SetUpClient(conn, string(id), user.ID) 122 | if isNewClient { 123 | room.MessageTo(client.ID, map[string]interface{}{ 124 | "type": "id", 125 | "id": client.ID, 126 | }) 127 | log.Printf("Client %s joined\n", client.ID) 128 | } else { 129 | log.Printf("Client %s reconnected\n", client.ID) 130 | } 131 | if isNewRoom || (isNewClient && numClients == 1) { 132 | // Send call notification to all users that are not in the call 133 | sendCallNotificationTo(vc.GetOwners(), &user, c.Query("url")) 134 | } 135 | room.Broadcast(client.ID, map[string]interface{}{ 136 | "type": "joined", 137 | "from": client.ID, 138 | }) 139 | removeClient := func() { 140 | room.Broadcast(client.ID, map[string]interface{}{ 141 | "type": "left", 142 | "from": client.ID, 143 | }) 144 | room.RemoveClient(client) 145 | } 146 | // Start client message loop 147 | for { 148 | _, message, err := conn.ReadMessage() 149 | if err != nil { 150 | log.Println(err) 151 | removeClient() 152 | break 153 | } 154 | log.Println("Got message:" + string(message)) 155 | 156 | var msg map[string]interface{} 157 | if err := json.Unmarshal(message, &msg); err != nil { 158 | log.Printf("Invalid JSON message: %s\n", string(message)) 159 | continue 160 | } 161 | switch msg["type"] { 162 | case "ping": 163 | room.SeenClient(client) 164 | case "leave": 165 | removeClient() 166 | log.Printf("Client %s left\n", client.ID) 167 | case "offer": 168 | fallthrough 169 | case "candidate": 170 | fallthrough 171 | case "answer": 172 | msg["from"] = client.ID 173 | recipient, exists := msg["to"].(string) 174 | if !exists || recipient == "" { 175 | log.Printf("Invalid message: %v\n", msg) 176 | continue 177 | } 178 | room.MessageTo(recipient, msg) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /handlers/faces.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "server/config" 6 | "server/db" 7 | "server/models" 8 | "strconv" 9 | "strings" 10 | 11 | _ "image/jpeg" 12 | 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | type FaceInfo struct { 17 | ID uint64 `json:"id"` 18 | Num int `json:"num"` 19 | PersonID uint64 `json:"person_id"` 20 | PersonName string `json:"person_name"` 21 | AsselID uint64 `json:"asset_id"` 22 | X1 int `json:"x1"` 23 | Y1 int `json:"y1"` 24 | X2 int `json:"x2"` 25 | Y2 int `json:"y2"` 26 | } 27 | 28 | type AssetsForFaceRequest struct { 29 | FaceID uint64 `form:"face_id" binding:"required"` 30 | Threshold float64 `form:"threshold"` 31 | } 32 | 33 | func FacesForAsset(c *gin.Context, user *models.User) { 34 | assetIDSt, exists := c.GetQuery("asset_id") 35 | if !exists { 36 | c.JSON(http.StatusBadRequest, Response{"Missing asset ID"}) 37 | return 38 | } 39 | assetID, err := strconv.ParseUint(assetIDSt, 10, 64) 40 | if err != nil { 41 | c.JSON(http.StatusBadRequest, Response{"Invalid asset ID"}) 42 | return 43 | } 44 | asset := models.Asset{ID: assetID} 45 | db.Instance.First(&asset) 46 | if asset.ID != assetID || asset.UserID != user.ID { 47 | c.JSON(http.StatusUnauthorized, NopeResponse) 48 | return 49 | } 50 | rows, err := db.Instance.Raw("select f.id, f.num, f.x1, f.y1, f.x2, f.y2, p.id, p.name from faces f left join people p on f.person_id=p.id where f.asset_id=?", assetID).Rows() 51 | if err != nil { 52 | c.JSON(http.StatusInternalServerError, DBError1Response) 53 | return 54 | } 55 | defer rows.Close() 56 | result := []FaceInfo{} 57 | for rows.Next() { 58 | face := FaceInfo{} 59 | pID := &face.PersonID 60 | pName := &face.PersonName 61 | if err = rows.Scan(&face.ID, &face.Num, &face.X1, &face.Y1, &face.X2, &face.Y2, &pID, &pName); err != nil { 62 | c.JSON(http.StatusInternalServerError, DBError2Response) 63 | return 64 | } 65 | if pID != nil { 66 | face.PersonID = *pID 67 | } 68 | if pName != nil { 69 | face.PersonName = *pName 70 | } 71 | result = append(result, face) 72 | } 73 | c.JSON(http.StatusOK, result) 74 | } 75 | 76 | func PeopleList(c *gin.Context, user *models.User) { 77 | // Do this in two steps. First load all people information 78 | rows, err := db.Instance.Raw("select id, name from people where user_id=?", user.ID).Rows() 79 | if err != nil { 80 | c.JSON(http.StatusInternalServerError, DBError1Response) 81 | return 82 | } 83 | // Put the info in the FaceInfo struct 84 | people := []FaceInfo{} 85 | for rows.Next() { 86 | person := FaceInfo{} 87 | if err = rows.Scan(&person.PersonID, &person.PersonName); err != nil { 88 | c.JSON(http.StatusInternalServerError, DBError2Response) 89 | rows.Close() 90 | return 91 | } 92 | people = append(people, person) 93 | } 94 | rows.Close() 95 | 96 | // Now load the last face for each person 97 | for i, person := range people { 98 | rows, err = db.Instance.Raw("select id, asset_id, num, x1, y1, x2, y2 from faces where person_id=? order by created_at desc limit 1", person.PersonID).Rows() 99 | if err != nil { 100 | c.JSON(http.StatusInternalServerError, DBError3Response) 101 | return 102 | } 103 | if rows.Next() { 104 | face := &people[i] 105 | if err = rows.Scan(&face.ID, &face.AsselID, &face.Num, &face.X1, &face.Y1, &face.X2, &face.Y2); err != nil { 106 | c.JSON(http.StatusInternalServerError, DBError4Response) 107 | rows.Close() 108 | return 109 | } 110 | } 111 | rows.Close() 112 | } 113 | c.JSON(http.StatusOK, people) 114 | } 115 | 116 | func CreatePerson(c *gin.Context, user *models.User) { 117 | var personFace FaceInfo 118 | err := c.ShouldBindJSON(&personFace) 119 | if err != nil { 120 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 121 | return 122 | } 123 | personFace.PersonName = strings.Trim(personFace.PersonName, " ") 124 | if personFace.PersonName == "" { 125 | c.JSON(http.StatusBadRequest, Response{"Empty person name"}) 126 | return 127 | } 128 | personModel := models.Person{Name: personFace.PersonName, UserID: user.ID} 129 | if db.Instance.Create(&personModel).Error != nil { 130 | c.JSON(http.StatusInternalServerError, DBError1Response) 131 | return 132 | } 133 | personFace.PersonID = personModel.ID 134 | c.JSON(http.StatusOK, personFace) 135 | } 136 | 137 | func PersonAssignFace(c *gin.Context, user *models.User) { 138 | var face FaceInfo 139 | err := c.ShouldBindJSON(&face) 140 | if err != nil { 141 | c.JSON(http.StatusInternalServerError, Response{err.Error()}) 142 | return 143 | } 144 | if face.PersonID == 0 { 145 | if face.ID == 0 { 146 | c.JSON(http.StatusBadRequest, Response{"Empty face ID and person ID"}) 147 | return 148 | } 149 | // We want to unassign a face from a person 150 | if db.Instance.Exec("update faces set person_id=null where id=?", face.ID).Error != nil { 151 | c.JSON(http.StatusInternalServerError, DBError1Response) 152 | return 153 | } 154 | face.PersonID = 0 155 | face.PersonName = "" 156 | c.JSON(http.StatusOK, face) 157 | return 158 | } 159 | // Check if this face.PersonID is the same as current user.ID 160 | person := models.Person{ID: face.PersonID} 161 | if db.Instance.First(&person).Error != nil || person.UserID != user.ID { 162 | c.JSON(http.StatusUnauthorized, NopeResponse) 163 | return 164 | } 165 | if db.Instance.Exec("update faces set person_id=? where id=?", face.PersonID, face.ID).Error != nil { 166 | c.JSON(http.StatusInternalServerError, DBError1Response) 167 | return 168 | } 169 | // threshold is squared by default 170 | thresholdStr := c.Query("threshold") 171 | threshold, _ := strconv.ParseFloat(thresholdStr, 64) 172 | if threshold == 0 { 173 | threshold = config.FACE_MAX_DISTANCE_SQ 174 | } 175 | // Set PersonID to all assets with faces similar to the given face based on threshold 176 | // Also, make sure the distance is greater than the current face's distance (i.e. the new face is more similar to the one detected before) 177 | if db.Instance.Exec(`update faces 178 | set person_id=? 179 | where id in ( 180 | select id from ( 181 | select t2.id 182 | from faces t1 join faces t2 183 | where t1.id=? and 184 | t1.id!=t2.id and 185 | t1.user_id=? and 186 | t1.user_id=t2.user_id and 187 | `+models.FacesVectorDistance+` <= ? and 188 | (t2.distance = 0 OR t2.distance > `+models.FacesVectorDistance+`) 189 | ) tmp 190 | )`, 191 | face.PersonID, face.ID, user.ID, threshold).Error != nil { 192 | 193 | c.JSON(http.StatusInternalServerError, DBError2Response) 194 | return 195 | } 196 | face.PersonName = person.Name 197 | c.JSON(http.StatusOK, face) 198 | } 199 | -------------------------------------------------------------------------------- /handlers/group.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "server/db" 6 | "server/models" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/gin-gonic/gin/binding" 10 | "github.com/gorilla/websocket" 11 | ) 12 | 13 | var upgrader = websocket.Upgrader{ 14 | ReadBufferSize: 4 * 1024, 15 | WriteBufferSize: 4 * 1024, 16 | CheckOrigin: func(r *http.Request) bool { 17 | return true 18 | }, 19 | } 20 | 21 | type GroupUserInfo struct { 22 | ID uint64 `json:"id"` 23 | Name string `json:"name"` 24 | IsAdmin bool `json:"is_admin"` 25 | SeenMessage uint64 `json:"seen_message"` 26 | } 27 | 28 | type GroupSeenMessage struct { 29 | GroupID uint64 `json:"group_id" binding:"required"` 30 | MessageID uint64 `json:"message_id" binding:"required"` 31 | } 32 | 33 | type GroupInfo struct { 34 | ID uint64 `json:"id" form:"id" binding:"required"` 35 | Name string `json:"name" form:"name"` 36 | Colour string `json:"colour" form:"colour"` 37 | Favourite bool `json:"favourite" form:"favourite"` 38 | IsAdmin bool `json:"is_admin"` 39 | Members []GroupUserInfo `json:"members"` 40 | SeenMessage uint64 `json:"seen_message"` 41 | } 42 | 43 | type GroupCreateRequest struct { 44 | Members []GroupUserInfo `json:"members" binding:"required"` 45 | } 46 | 47 | type MessagesRequest struct { 48 | SinceID uint64 `json:"since_id" form:"since_message"` 49 | } 50 | 51 | type GroupDeleteRequest struct { 52 | ID uint64 `json:"id" binding:"required"` 53 | } 54 | 55 | func InviteToGroup(c *gin.Context) { 56 | } 57 | 58 | func (gi *GroupInfo) loadMembers() { 59 | rows, err := db.Instance. 60 | Table("group_users"). 61 | Joins("join `users` on group_users.user_id = `users`.id"). 62 | Select("user_id, name, is_admin, seen_message"). 63 | Where("group_id = ?", gi.ID). 64 | Order("group_users.created_at"). 65 | Rows() 66 | 67 | if err != nil { 68 | return 69 | } 70 | defer rows.Close() 71 | gi.Members = []GroupUserInfo{} 72 | for rows.Next() { 73 | userInfo := GroupUserInfo{} 74 | if err = rows.Scan(&userInfo.ID, &userInfo.Name, &userInfo.IsAdmin, &userInfo.SeenMessage); err != nil { 75 | continue 76 | } 77 | gi.Members = append(gi.Members, userInfo) 78 | } 79 | } 80 | 81 | func GroupList(c *gin.Context, user *models.User) { 82 | rows, err := db.Instance. 83 | Table("group_users"). 84 | Joins("join `groups` on group_users.group_id = `groups`.id"). 85 | Select("group_id, name, colour, is_favourite, is_admin, seen_message"). 86 | Where("user_id = ?", user.ID). 87 | Order("is_favourite DESC, `groups`.updated_at DESC"). 88 | Rows() 89 | 90 | if err != nil { 91 | c.JSON(http.StatusInternalServerError, DBError1Response) 92 | return 93 | } 94 | defer rows.Close() 95 | result := []GroupInfo{} 96 | isGlobalAdmin := user.HasPermission(models.PermissionAdmin) 97 | for rows.Next() { 98 | groupInfo := GroupInfo{} 99 | if err = rows.Scan(&groupInfo.ID, &groupInfo.Name, &groupInfo.Colour, &groupInfo.Favourite, &groupInfo.IsAdmin, &groupInfo.SeenMessage); err != nil { 100 | c.JSON(http.StatusInternalServerError, DBError2Response) 101 | return 102 | } 103 | if isGlobalAdmin { 104 | groupInfo.IsAdmin = true 105 | } 106 | groupInfo.loadMembers() 107 | result = append(result, groupInfo) 108 | } 109 | c.JSON(http.StatusOK, result) 110 | } 111 | 112 | // GroupCreate creates a Group object and also a GroupUser for the current user 113 | func GroupCreate(c *gin.Context, user *models.User) { 114 | if !user.HasPermission(models.PermissionAdmin) && !user.HasPermission(models.PermissionCanCreateGroups) { 115 | c.JSON(http.StatusUnauthorized, NopeResponse) 116 | return 117 | } 118 | r := GroupCreateRequest{} 119 | err := c.ShouldBindJSON(&r) 120 | if err != nil { 121 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 122 | return 123 | } 124 | group := models.Group{ 125 | CreatedByID: user.ID, 126 | } 127 | result := db.Instance.Create(&group) 128 | if result.Error != nil { 129 | c.JSON(http.StatusInternalServerError, DBError1Response) 130 | return 131 | } 132 | // Now create the Group <-> User link 133 | for _, gu := range r.Members { 134 | groupUser := models.GroupUser{ 135 | GroupID: group.ID, 136 | UserID: gu.ID, 137 | IsAdmin: gu.IsAdmin, 138 | } 139 | result = db.Instance.Create(&groupUser) 140 | if result.Error != nil { 141 | c.JSON(http.StatusInternalServerError, DBError2Response) 142 | return 143 | } 144 | } 145 | // Send notification to all members 146 | newMembersMessage := NewGroupUpdate(group.ID, GroupUpdateValueNew, user.Name+" added you to a new group", "Check your groups and start chatting", "") 147 | members := models.LoadGroupUserIDs(group.ID) 148 | delete(members, user.ID) 149 | go sendToSocketAndPush(&newMembersMessage, members) 150 | 151 | c.JSON(http.StatusOK, GroupInfo{ 152 | ID: group.ID, 153 | Members: r.Members, 154 | IsAdmin: true, 155 | }) 156 | } 157 | 158 | // GroupSave updates the Group and GroupUser objects for the current user 159 | func GroupSave(c *gin.Context, user *models.User) { 160 | r := GroupInfo{} 161 | err := c.ShouldBindJSON(&r) 162 | if err != nil { 163 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 164 | return 165 | } 166 | // Load the GroupUser object 167 | groupUser := models.GroupUser{ 168 | GroupID: r.ID, 169 | UserID: user.ID, 170 | } 171 | result := db.Instance.First(&groupUser) 172 | if result.Error != nil { 173 | c.JSON(http.StatusInternalServerError, DBError1Response) 174 | return 175 | } 176 | // Update the fields 177 | groupUser.Colour = r.Colour 178 | groupUser.IsFavourite = r.Favourite 179 | result = db.Instance.Save(&groupUser) 180 | if result.Error != nil { 181 | c.JSON(http.StatusInternalServerError, DBError2Response) 182 | return 183 | } 184 | // Load the Group object 185 | group := models.Group{ID: r.ID} 186 | result = db.Instance.Preload("Members").First(&group) 187 | if result.Error != nil { 188 | c.JSON(http.StatusInternalServerError, DBError3Response) 189 | return 190 | } 191 | lastMembersTokenMap := models.LoadGroupUserIDs(group.ID) 192 | sameMembers := len(lastMembersTokenMap) == len(r.Members) 193 | if sameMembers { 194 | for _, member := range r.Members { 195 | if _, present := lastMembersTokenMap[member.ID]; !present { 196 | sameMembers = false 197 | } 198 | } 199 | } 200 | // Update name 201 | if (groupUser.IsAdmin || user.HasPermission(models.PermissionAdmin)) && group.Name != r.Name { 202 | group.Name = r.Name 203 | if db.Instance.Omit("Members").Save(&group).Error != nil { 204 | c.JSON(http.StatusInternalServerError, DBError4Response) 205 | return 206 | } 207 | // Send notification to current members 208 | updateMessage := NewGroupUpdate(group.ID, GroupUpdateValueNameChange, user.Name+" changed the group name to '"+r.Name+"'", "Check '"+r.Name+"'", r.Name) 209 | go sendToSocketAndPush(&updateMessage, lastMembersTokenMap) 210 | } 211 | // Update members? 212 | if (groupUser.IsAdmin || user.HasPermission(models.PermissionAdmin)) && !sameMembers { 213 | // We can edit the Group object... 214 | newMembersMap := map[uint64]bool{} 215 | for _, m := range r.Members { 216 | newMembersMap[m.ID] = m.IsAdmin 217 | delete(lastMembersTokenMap, m.ID) 218 | } 219 | oldMembersMap := map[uint64]bool{} 220 | // Modify the current members 221 | for _, member := range group.Members { 222 | if isAdmin, ok := newMembersMap[member.UserID]; ok { 223 | // Just update the old GroupUser objects as they contain preferences 224 | member.IsAdmin = isAdmin 225 | db.Instance.Save(&member) 226 | } else { 227 | // Remove deleted ones 228 | db.Instance.Delete(&member) 229 | } 230 | oldMembersMap[member.UserID] = member.IsAdmin 231 | } 232 | // Add new members 233 | for _, m := range r.Members { 234 | if _, ok := oldMembersMap[m.ID]; ok { 235 | continue 236 | } 237 | db.Instance.Save(&models.GroupUser{ 238 | GroupID: group.ID, 239 | UserID: m.ID, 240 | IsAdmin: m.IsAdmin, 241 | }) 242 | } 243 | if db.Instance.Omit("Members").Save(&group).Error != nil { 244 | c.JSON(http.StatusInternalServerError, DBError4Response) 245 | return 246 | } 247 | // Send notification to new members 248 | newMembersTokenMap := models.LoadGroupUserIDs(group.ID) 249 | for k := range newMembersTokenMap { 250 | if _, exists := newMembersMap[k]; !exists { 251 | delete(newMembersTokenMap, k) 252 | } 253 | } 254 | newMembersMessage := NewGroupUpdate(group.ID, GroupUpdateValueNew, user.Name+" added you to a new chat", "Check your groups and start chatting", "") 255 | go sendToSocketAndPush(&newMembersMessage, newMembersTokenMap) 256 | // Send notification to retired members 257 | retiredMembersMessage := NewGroupUpdate(group.ID, GroupUpdateValueLeft, "", "", "") 258 | go sendToSocketAndPush(&retiredMembersMessage, lastMembersTokenMap) 259 | } 260 | c.JSON(http.StatusOK, GroupInfo{ 261 | ID: group.ID, 262 | Name: group.Name, 263 | Colour: groupUser.Colour, 264 | Favourite: groupUser.IsFavourite, 265 | IsAdmin: groupUser.IsAdmin, 266 | }) 267 | } 268 | 269 | // GroupDelete deletes the Group and all of its dependants (via foreign keys) 270 | func GroupDelete(c *gin.Context, user *models.User) { 271 | r := GroupDeleteRequest{} 272 | err := c.ShouldBindWith(&r, binding.JSON) 273 | if err != nil { 274 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 275 | return 276 | } 277 | // Load the GroupUser object 278 | groupUser := models.GroupUser{ 279 | GroupID: r.ID, 280 | UserID: user.ID, 281 | } 282 | if db.Instance.First(&groupUser).Error != nil { 283 | c.JSON(http.StatusInternalServerError, DBError1Response) 284 | return 285 | } 286 | if !groupUser.IsAdmin && !user.HasPermission(models.PermissionAdmin) { 287 | c.JSON(http.StatusUnauthorized, NopeResponse) 288 | return 289 | } 290 | members := models.LoadGroupUserIDs(r.ID) 291 | delete(members, user.ID) 292 | leftMessage := NewGroupUpdate(r.ID, GroupUpdateValueLeft, "", "", "") 293 | 294 | // Delete the Group object 295 | group := models.Group{ID: r.ID} 296 | result := db.Instance.Delete(&group) 297 | if result.Error != nil { 298 | c.JSON(http.StatusInternalServerError, DBError1Response) 299 | return 300 | } 301 | // Send WS message to ex-members 302 | go sendToSocketAndPush(&leftMessage, members) 303 | 304 | c.JSON(http.StatusOK, OKResponse) 305 | } 306 | -------------------------------------------------------------------------------- /handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type Response struct { 12 | Error string `json:"error"` 13 | } 14 | 15 | type MultiResponse struct { 16 | Error string `json:"error"` 17 | Failed []uint64 `json:"failed"` 18 | } 19 | 20 | var ( 21 | // Predefined errors 22 | OKResponse = Response{} 23 | NopeResponse = Response{"nope"} 24 | Nope2Response = Response{"no no"} 25 | Nope3Response = Response{"no no no"} 26 | DBError1Response = Response{"DB Error 1"} 27 | DBError2Response = Response{"DB Error 2"} 28 | DBError3Response = Response{"DB Error 3"} 29 | DBError4Response = Response{"DB Error 4"} 30 | OKMultiResponse = MultiResponse{"", []uint64{}} 31 | ) 32 | 33 | func isNotModified(c *gin.Context, tx *gorm.DB) bool { 34 | row := tx.Row() 35 | lastUpdatedAt := uint64(0) 36 | if row.Scan(&lastUpdatedAt) != nil { 37 | return false 38 | } 39 | // Set the current ETag 40 | c.Header("cache-control", "private, max-age=1") 41 | c.Header("etag", strconv.FormatUint(lastUpdatedAt, 10)) 42 | 43 | // ETag contains last updated asset time 44 | remoteLastUpdatedAt, _ := strconv.ParseUint(c.Request.Header.Get("if-none-match"), 10, 64) 45 | if remoteLastUpdatedAt == lastUpdatedAt { 46 | c.Status(http.StatusNotModified) 47 | return true 48 | } 49 | return false 50 | } 51 | -------------------------------------------------------------------------------- /handlers/message.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "server/db" 8 | "server/models" 9 | "server/push" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | const ( 16 | TypeGroupMessage = "group_message" 17 | TypeGroupUpdate = "group_update" 18 | TypeSeenMessage = "seen_message" 19 | 20 | GroupUpdateValueNew = "new" 21 | GroupUpdateValueLeft = "left" 22 | GroupUpdateValueNameChange = "name_change" 23 | ) 24 | 25 | type NotificationGetter interface { 26 | getNotification() *push.Notification 27 | } 28 | 29 | type Message struct { 30 | Type string `json:"type"` 31 | Stamp int64 `json:"stamp"` 32 | } 33 | 34 | type GroupMessage struct { 35 | Message 36 | Data models.GroupMessage `json:"data"` 37 | } 38 | 39 | type GroupUpdateDetails struct { 40 | GroupID uint64 `json:"group_id"` 41 | Value string `json:"value"` 42 | Title string `json:"title"` 43 | Body string `json:"body"` 44 | Name string `json:"name"` 45 | } 46 | 47 | type SeenMessageDetails struct { 48 | ID uint64 `json:"id"` 49 | GroupID uint64 `json:"group_id"` 50 | UserID uint64 `json:"user_id"` 51 | } 52 | 53 | type SeenMessage struct { 54 | Message 55 | Data SeenMessageDetails `json:"data"` 56 | } 57 | 58 | type GroupUpdate struct { 59 | Message 60 | Data GroupUpdateDetails `json:"data"` 61 | } 62 | 63 | func (sm *SeenMessage) getNotification() *push.Notification { 64 | return nil 65 | } 66 | 67 | func (gm *GroupMessage) getNotification() *push.Notification { 68 | if gm.Data.ReactionTo > 0 { 69 | // TODO: Implement reaction notifications - but only to the parent message author 70 | // title = gm.Data.UserName + " reacted" 71 | return nil 72 | } 73 | body := gm.Data.Content 74 | if strings.HasPrefix(body, "[image:http") { 75 | body = "[image]" 76 | } 77 | title := gm.Data.UserName 78 | if len(gm.Data.Group.Name) > 0 { 79 | title = gm.Data.UserName + " to " + gm.Data.Group.Name 80 | } 81 | return &push.Notification{ 82 | Title: title, 83 | Body: body, 84 | Data: map[string]string{ 85 | "type": TypeGroupMessage, 86 | "group": strconv.FormatUint(gm.Data.GroupID, 10), 87 | }, 88 | } 89 | } 90 | 91 | func (gu *GroupUpdate) getNotification() *push.Notification { 92 | if gu.Data.Title == "" { 93 | return nil 94 | } 95 | return &push.Notification{ 96 | Title: gu.Data.Title, 97 | Body: gu.Data.Body, 98 | Data: map[string]string{ 99 | "type": TypeGroupUpdate, 100 | "group": strconv.FormatUint(gu.Data.GroupID, 10), 101 | }, 102 | } 103 | } 104 | 105 | func NewSeenMessage(groupID, messageID, userID uint64) (m SeenMessage) { 106 | m.Message.Type = TypeSeenMessage 107 | m.Message.Stamp = time.Now().UnixMilli() 108 | m.Data.ID = messageID 109 | m.Data.GroupID = groupID 110 | m.Data.UserID = userID 111 | return 112 | } 113 | 114 | func NewGroupMessage() (m GroupMessage) { 115 | m.Message.Type = TypeGroupMessage 116 | m.Message.Stamp = time.Now().UnixMilli() 117 | return 118 | } 119 | 120 | func NewGroupUpdate(groupID uint64, value, title, body, name string) (m GroupUpdate) { 121 | m.Message.Type = TypeGroupUpdate 122 | m.Message.Stamp = time.Now().UnixMilli() 123 | m.Data.GroupID = groupID 124 | m.Data.Value = value 125 | m.Data.Title = title 126 | m.Data.Body = body 127 | m.Data.Name = name 128 | return 129 | } 130 | 131 | func sendToSocketAndPush(message NotificationGetter, recipients map[uint64]string) { 132 | buffer := bytes.Buffer{} 133 | _ = json.NewEncoder(&buffer).Encode(message) 134 | notification := message.getNotification() 135 | pushTokens := make([]string, 0, len(recipients)) 136 | for userID, pushToken := range recipients { 137 | pushTokens = append(pushTokens, pushToken) 138 | clientID := models.GetUserSocketID(userID) 139 | connections, exist := connectedUsers.Get(clientID) 140 | if !exist { 141 | continue 142 | } 143 | // TODO: If initiator, send only confirmation 144 | for _, conn := range connections { 145 | if !conn.sendFunc(buffer.Bytes()) { 146 | conn.removeFrom(clientID) 147 | } 148 | } 149 | } 150 | // Always send push notification 151 | if notification != nil { 152 | notification.SendTo(pushTokens) 153 | } 154 | } 155 | 156 | func processMessage(user *models.User, data []byte) { 157 | message := Message{} 158 | if err := json.Unmarshal(data, &message); err != nil { 159 | return 160 | } 161 | switch message.Type { 162 | case TypeGroupMessage: 163 | groupMessage := GroupMessage{} 164 | if err := json.Unmarshal(data, &groupMessage); err != nil { 165 | log.Printf("Not a Group message: %v", err) 166 | return 167 | } 168 | log.Printf("Group message: %+v", groupMessage) 169 | recipients := models.GetGroupRecipients(groupMessage.Data.GroupID, user) 170 | if len(recipients) == 0 { 171 | log.Printf("User %d does not belong to group %d", user.ID, groupMessage.Data.GroupID) 172 | return 173 | } 174 | groupMessage.saveFor(user) 175 | groupMessage.propagateToGroupUsers(recipients) 176 | 177 | case TypeSeenMessage: 178 | seenMessage := SeenMessage{} 179 | if err := json.Unmarshal(data, &seenMessage); err != nil { 180 | log.Printf("Not a Seen message: %v", err) 181 | return 182 | } 183 | log.Printf("Seen message: %+v", seenMessage) 184 | recipients := models.GetGroupRecipients(seenMessage.Data.GroupID, user) 185 | if len(recipients) == 0 { 186 | log.Printf("User %d does not belong to group %d", user.ID, seenMessage.Data.GroupID) 187 | return 188 | } 189 | // Update the GroupUser object for the current user and set the SeenMessage field 190 | err := db.Instance.Exec("update group_users set seen_message = ? where group_id = ? and user_id = ?", seenMessage.Data.ID, seenMessage.Data.GroupID, user.ID).Error 191 | if err != nil { 192 | log.Printf("SeenMessage udpate DB error: %v", err) 193 | return 194 | } 195 | sendToSocketAndPush(&seenMessage, recipients) 196 | } 197 | } 198 | 199 | func (message *GroupMessage) saveFor(initiator *models.User) { 200 | groupMessage := &message.Data 201 | groupMessage.ServerStamp = time.Now().UnixMilli() 202 | groupMessage.UserName = initiator.Name 203 | groupMessage.UserID = initiator.ID 204 | err := db.Instance.Save(groupMessage).Error 205 | if err != nil || groupMessage.ID == 0 { 206 | log.Printf("Couldn't save GroupMessage: %+v, err: %v", *groupMessage, err) 207 | return 208 | } 209 | } 210 | 211 | func (message *GroupMessage) propagateToGroupUsers(recipients map[uint64]string) { 212 | groupMessage := &message.Data 213 | message.Stamp = groupMessage.ServerStamp 214 | // Just for notification purposes 215 | if len(recipients) > 2 { 216 | db.Instance.First(&groupMessage.Group, groupMessage.GroupID) 217 | if len(groupMessage.Group.Name) == 0 { 218 | groupMessage.Group.Name = "your group" 219 | } 220 | } 221 | // Propagate 222 | sendToSocketAndPush(message, recipients) 223 | } 224 | -------------------------------------------------------------------------------- /handlers/moment.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "server/db" 6 | "server/models" 7 | "server/utils" 8 | "strings" 9 | 10 | _ "image/jpeg" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | type MomentInfo struct { 16 | Places string `json:"places" form:"places" binding:"required"` 17 | Name string `json:"name"` 18 | Subtitle string `json:"subtitle"` 19 | HeroAssetId uint64 `json:"hero_asset_id"` 20 | Start int64 `json:"start" form:"start" binding:"required"` 21 | End int64 `json:"end" form:"end" binding:"required"` 22 | } 23 | 24 | func (m *MomentInfo) merge(a *MomentInfo) { 25 | m.Start = a.Start 26 | 27 | places := map[string]bool{} 28 | for _, p := range strings.Split(m.Places, ",") { 29 | places[p] = true 30 | } 31 | for _, p := range strings.Split(a.Places, ",") { 32 | places[p] = true 33 | } 34 | newPlaces := []string{} 35 | for p := range places { 36 | newPlaces = append(newPlaces, p) 37 | } 38 | m.Places = strings.Join(newPlaces, ",") 39 | m.Subtitle = utils.GetDatesString(m.Start, m.End) 40 | } 41 | 42 | func MomentList(c *gin.Context, user *models.User) { 43 | // TODO: Minimum number of assets for a location should be configurable (now 6 below) 44 | rows, err := db.Instance.Raw(` 45 | select date, 46 | CASE 47 | WHEN city = '' THEN area 48 | ELSE city 49 | END, 50 | group_concat(place_id) places, 51 | max(hero), 52 | min(start), 53 | max(end) 54 | from (select place_id, 55 | `+db.CreatedDateFunc+` date, 56 | max(id) hero, 57 | count(*) cnt, 58 | min(created_at) start, 59 | max(created_at) end 60 | from assets 61 | where user_id = ? 62 | and deleted = 0 63 | and place_id is not null 64 | group by 2, 1 65 | having cnt > 6 66 | order by 2, 1 desc) t 67 | join places 68 | on id = place_id 69 | group by 1, 70 | 2 71 | order by 1 desc, 72 | 2 73 | `, user.ID).Rows() 74 | if err != nil { 75 | c.JSON(http.StatusInternalServerError, DBError1Response) 76 | return 77 | } 78 | defer rows.Close() 79 | result := []MomentInfo{} 80 | var date string 81 | lastMoment := &MomentInfo{} 82 | for rows.Next() { 83 | momentInfo := MomentInfo{} 84 | if err = rows.Scan(&date, &momentInfo.Name, &momentInfo.Places, &momentInfo.HeroAssetId, &momentInfo.Start, &momentInfo.End); err != nil { 85 | c.JSON(http.StatusInternalServerError, DBError2Response) 86 | return 87 | } 88 | // Should we merge last moment with this one? 89 | if lastMoment.Name == momentInfo.Name && lastMoment.Start-momentInfo.End < 2*86400 { 90 | lastMoment.merge(&momentInfo) 91 | continue 92 | } 93 | momentInfo.Subtitle = utils.GetDatesString(momentInfo.Start, momentInfo.End) 94 | result = append(result, momentInfo) 95 | lastMoment = &result[len(result)-1] 96 | } 97 | c.JSON(http.StatusOK, result) 98 | } 99 | 100 | func MomentAssets(c *gin.Context, user *models.User) { 101 | r := MomentInfo{} 102 | err := c.ShouldBindQuery(&r) 103 | if err != nil { 104 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 105 | return 106 | } 107 | rows, err := db.Instance. 108 | Table("assets"). 109 | Select(AssetsSelectClause). 110 | Joins("left join favourite_assets on favourite_assets.asset_id = assets.id"). 111 | Joins(LeftJoinForLocations). 112 | Where("assets.user_id = ? and place_id in (?) and assets.deleted=0 and assets.created_at>=? and assets.created_at<=?", user.ID, strings.Split(r.Places, ","), r.Start, r.End). 113 | Order("assets.created_at DESC").Rows() 114 | 115 | if err != nil { 116 | c.JSON(http.StatusInternalServerError, DBError1Response) 117 | return 118 | } 119 | defer rows.Close() 120 | result := LoadAssetsFromRows(c, rows) 121 | if result == nil { 122 | return 123 | } 124 | c.JSON(http.StatusOK, result) 125 | } 126 | -------------------------------------------------------------------------------- /handlers/tag.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "server/db" 6 | "server/models" 7 | "server/utils" 8 | "sort" 9 | "strconv" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | const ( 15 | tagTypePlace = 1 16 | tagTypePerson = 2 17 | tagTypeYear = 3 18 | tagTypeMonth = 4 19 | tagTypeDay = 5 20 | tagTypeSeason = 6 21 | tagTypeType = 7 22 | tagTypeFavourite = 8 23 | tagTypeAlbum = 9 24 | ) 25 | 26 | type Tag struct { 27 | Type int `json:"t"` 28 | Value string `json:"v"` 29 | Assets []uint64 `json:"a"` 30 | } 31 | type Tags map[string]Tag 32 | 33 | func (t *Tag) toIndex() string { 34 | return strconv.Itoa(t.Type) + "_" + t.Value 35 | } 36 | 37 | func (t *Tags) toArray() []Tag { 38 | result := []Tag{} 39 | for _, v := range *t { 40 | result = append(result, v) 41 | } 42 | return result 43 | } 44 | 45 | func (t *Tags) add(typ int, val any, assetId uint64) { 46 | if val == nil { 47 | return 48 | } 49 | tag := Tag{} 50 | if s, ok := val.(*string); ok && s != nil && *s != "" { 51 | tag = Tag{typ, *s, []uint64{assetId}} 52 | } else if st, ok := val.(string); ok && st != "" { 53 | tag = Tag{typ, st, []uint64{assetId}} 54 | } else if i, ok := val.(int); ok { 55 | tag = Tag{typ, strconv.Itoa(i), []uint64{assetId}} 56 | } else { 57 | return 58 | } 59 | tagIndex := tag.toIndex() 60 | if _, exists := (*t)[tagIndex]; !exists { 61 | (*t)[tagIndex] = tag 62 | return 63 | } 64 | tag = (*t)[tagIndex] 65 | tag.Assets = append(tag.Assets, assetId) 66 | (*t)[tagIndex] = tag 67 | } 68 | 69 | func TagList(c *gin.Context, user *models.User) { 70 | // Modified depends on deleted assets as well, that's why the where condition is different 71 | tx := db.Instance.Table("assets").Select("max(updated_at)").Where("user_id=? AND size>0 AND thumb_size>0", user.ID) 72 | if c.Query("reload") != "1" && isNotModified(c, tx) { 73 | return 74 | } 75 | rows, err := db.Instance.Table("assets").Select("id, mime_type, favourite, created_at, locations.gps_lat, locations.gps_long, area, city, country"). 76 | Where("user_id=? AND deleted=0 AND size>0 AND thumb_size>0", user.ID). 77 | Joins(LeftJoinForLocations). 78 | Order("created_at DESC"). 79 | Rows() 80 | if err != nil { 81 | c.JSON(http.StatusInternalServerError, DBError1Response) 82 | return 83 | } 84 | defer rows.Close() 85 | tags := Tags{} 86 | mimeType := "" 87 | var assetId uint64 88 | var createdAt int64 89 | var gpsLat, gpsLong *float64 90 | var area, city, country *string 91 | favourite := false 92 | for rows.Next() { 93 | if err = rows.Scan(&assetId, &mimeType, &favourite, &createdAt, &gpsLat, &gpsLong, &area, &city, &country); err != nil { 94 | c.JSON(http.StatusInternalServerError, DBError2Response) 95 | return 96 | } 97 | // Add location tags, e.g. "Tokyo", "Matsubara", etc 98 | tags.add(tagTypePlace, area, assetId) 99 | tags.add(tagTypePlace, city, assetId) 100 | tags.add(tagTypePlace, country, assetId) 101 | // Add time tags, e.g "2023", "April", "22" 102 | tmpAsset := &models.Asset{ 103 | CreatedAt: createdAt, 104 | GpsLat: gpsLat, 105 | GpsLong: gpsLong, 106 | } 107 | // Time zone for the given GPS coordinates is used 108 | year, month, day := tmpAsset.GetCreatedTimeInLocation().Date() 109 | tags.add(tagTypeYear, year, assetId) 110 | tags.add(tagTypeMonth, month.String(), assetId) 111 | tags.add(tagTypeDay, day, assetId) 112 | // Add season 113 | tags.add(tagTypeSeason, utils.GetSeason(month, gpsLat), assetId) 114 | // Add type 115 | if GetTypeFrom(mimeType) == models.AssetTypeVideo { 116 | tags.add(tagTypeType, "Video", assetId) 117 | } 118 | // TODO: add album names? 119 | // Add favourites 120 | if favourite { 121 | tags.add(tagTypeFavourite, "Favourite", assetId) 122 | } 123 | } 124 | // Find all people, all their faces in assets and add them as tags 125 | rows, err = db.Instance.Raw("select p.id, p.name, f.asset_id from people p join faces f on f.person_id=p.id").Rows() 126 | if err != nil { 127 | c.JSON(http.StatusInternalServerError, DBError3Response) 128 | return 129 | } 130 | var personId uint64 131 | var personName string 132 | defer rows.Close() 133 | for rows.Next() { 134 | if err = rows.Scan(&personId, &personName, &assetId); err != nil { 135 | c.JSON(http.StatusInternalServerError, DBError4Response) 136 | return 137 | } 138 | tags.add(tagTypePerson, personName, assetId) 139 | } 140 | 141 | result := tags.toArray() 142 | // Sort tags by popularity (num assets) 143 | sort.Slice(result, func(i, j int) bool { 144 | return len(result[i].Assets) > len(result[j].Assets) 145 | }) 146 | c.JSON(http.StatusOK, result) 147 | } 148 | -------------------------------------------------------------------------------- /handlers/upload.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "server/db" 6 | "server/models" 7 | 8 | _ "image/jpeg" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type UploadShareResponse struct { 14 | Path string `json:"path"` 15 | } 16 | 17 | func UploadShare(c *gin.Context, user *models.User) { 18 | shareInfo := models.NewUploadRequest(user.ID) 19 | result := db.Instance.Create(&shareInfo) 20 | if result.Error != nil { 21 | c.JSON(http.StatusInternalServerError, DBError1Response) 22 | return 23 | } 24 | c.JSON(http.StatusOK, UploadShareResponse{"/w/upload/" + shareInfo.Token + "/"}) 25 | } 26 | -------------------------------------------------------------------------------- /handlers/user.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net/http" 7 | "server/auth" 8 | "server/db" 9 | "server/models" 10 | "server/utils" 11 | "strings" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/gin-gonic/gin/binding" 15 | ) 16 | 17 | type UserLoginRequest struct { 18 | Email string `json:"email" binding:"required"` 19 | Password string `json:"password" binding:"required"` 20 | Token string `json:"token"` 21 | New bool `json:"new"` 22 | } 23 | 24 | type UserInfo struct { 25 | ID uint64 `json:"id"` 26 | Name string `json:"name"` 27 | Email string `json:"email"` 28 | Permissions []int `json:"permissions"` 29 | Bucket uint64 `json:"bucket"` 30 | Quota int64 `json:"quota"` // in MB 31 | } 32 | 33 | type UserStatusResponse struct { 34 | Error string `json:"error"` 35 | Name string `json:"name"` 36 | UserID uint64 `json:"user_id"` 37 | PushToken string `json:"push_token"` 38 | Permissions []int `json:"permissions"` 39 | BucketUsage int64 `json:"bucket_usage"` 40 | BucketQuota int64 `json:"bucket_quota"` 41 | } 42 | 43 | type UserSaveResponse struct { 44 | Error string `json:"error"` 45 | Token string `json:"token"` 46 | } 47 | 48 | func isValidLogin(l string) bool { 49 | return !strings.ContainsAny(l, " \t\n\r") && 50 | len(l) > 0 && 51 | ((l[0] >= 'a' && l[0] <= 'z') || 52 | (l[0] >= 'A' && l[0] <= 'Z') || 53 | (l[0] >= '0' && l[0] <= '9')) 54 | } 55 | 56 | func createFromToken(postReq *UserLoginRequest) (err error) { 57 | user := models.User{} 58 | if db.Instance.Where("email = ? and password = ''", postReq.Token).Find(&user).Error != nil || user.ID == 0 { 59 | return errors.New("invalid token") 60 | } 61 | if !isValidLogin(postReq.Email) { 62 | return errors.New("login cannot contain empty spaces and must start with a letter or a number") 63 | } 64 | user.Email = postReq.Email 65 | user.SetPassword(postReq.Password) 66 | err = db.Instance.Where("id = ?", user.ID).Updates(&models.User{ 67 | Email: user.Email, 68 | Password: user.Password, 69 | PassSalt: user.PassSalt, 70 | }).Error 71 | if err != nil { 72 | return errors.New("user with the same login seems to exist") 73 | } 74 | return nil 75 | } 76 | 77 | func createFirstUser(postReq *UserLoginRequest) (err error) { 78 | user, err := models.UserCreate(postReq.Email, postReq.Email, postReq.Password) 79 | if err != nil { 80 | return errors.New("DB error 2") 81 | } 82 | // Add all permissions to the first user 83 | for _, permission := range models.AllPermissions { 84 | err = db.Instance.Save(&models.Grant{ 85 | GrantorID: user.ID, 86 | UserID: user.ID, 87 | Permission: permission, 88 | }).Error 89 | if err != nil { 90 | return errors.New("DB error 3") 91 | } 92 | } 93 | return nil 94 | } 95 | 96 | func newUserStatusResponse(user *models.User, details bool) UserStatusResponse { 97 | result := UserStatusResponse{ 98 | UserID: user.ID, 99 | Name: user.Name, 100 | Permissions: user.GetPermissions(), 101 | BucketUsage: -1, 102 | BucketQuota: -1, 103 | } 104 | if details { 105 | if user.PushToken == "" { 106 | user.SetNewPushToken() 107 | } 108 | result.PushToken = user.PushToken 109 | result.BucketQuota = user.Quota 110 | result.BucketUsage = user.GetUsage() 111 | if result.BucketQuota == 0 { 112 | // Unlimited - return the actual storage available space (if possible) 113 | available, _ := user.Bucket.GetSpaceInfo() 114 | if available >= 0 { 115 | result.BucketQuota = available / 1024 / 1024 116 | } 117 | } 118 | } 119 | return result 120 | } 121 | 122 | func numUsers() (result int) { 123 | db.Instance.Raw("select count(*) from users").Scan(&result) 124 | return 125 | } 126 | 127 | func UserLogin(c *gin.Context) { 128 | postReq := UserLoginRequest{} 129 | err := c.ShouldBindJSON(&postReq) 130 | if err != nil { 131 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 132 | return 133 | } 134 | if postReq.Token != "" { 135 | // New user has been invited 136 | if err = createFromToken(&postReq); err != nil { 137 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 138 | return 139 | } 140 | } else if postReq.New { 141 | // Check if we have a brand new instance 142 | if numUsers() != 0 { 143 | c.JSON(http.StatusForbidden, NopeResponse) 144 | return 145 | } 146 | if err = createFirstUser(&postReq); err != nil { 147 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 148 | return 149 | } 150 | } 151 | // Proceed with standard login 152 | user, success := models.UserLogin(postReq.Email, postReq.Password) 153 | if !success { 154 | c.JSON(http.StatusUnauthorized, Response{"Incorrect username or password"}) 155 | return 156 | } 157 | session := auth.LoadSession(c) 158 | session.Set("id", user.ID) 159 | _ = session.Save() 160 | 161 | c.JSON(http.StatusOK, newUserStatusResponse(&user, false)) 162 | } 163 | 164 | func cleanupName(name string) string { 165 | name = strings.Trim(name, " \n\r") 166 | for strings.Contains(name, " ") { 167 | name = strings.ReplaceAll(name, " ", " ") 168 | } 169 | if len(name) > 50 { 170 | name = name[:50] 171 | } 172 | return name 173 | } 174 | 175 | func UserSave(c *gin.Context, adminUser *models.User) { 176 | req := UserInfo{} 177 | err := c.ShouldBindWith(&req, binding.JSON) 178 | if err != nil { 179 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 180 | return 181 | } 182 | if req.Bucket == 0 { 183 | c.JSON(http.StatusBadRequest, Response{"select storage bucket"}) 184 | return 185 | } 186 | // Cleanup 187 | req.Name = cleanupName(req.Name) 188 | if req.Name == "" { 189 | c.JSON(http.StatusBadRequest, Response{"empty name"}) 190 | return 191 | } 192 | token := "" 193 | user := models.User{ID: req.ID} 194 | if user.ID > 0 { 195 | if err = db.Instance.Preload("Grants").Find(&user).Error; err != nil { 196 | c.JSON(http.StatusInternalServerError, DBError1Response) 197 | return 198 | } 199 | } else { 200 | // New user with random email (login) 201 | // They will choose their login later 202 | user.Email = utils.Rand16BytesToBase62() 203 | token = user.Email 204 | } 205 | user.BucketID = &req.Bucket 206 | user.Quota = req.Quota 207 | user.Name = req.Name 208 | for _, g := range user.Grants { 209 | db.Instance.Delete(&g) 210 | } 211 | user.Grants = []models.Grant{} 212 | for _, p := range req.Permissions { 213 | user.Grants = append(user.Grants, models.Grant{ 214 | GrantorID: adminUser.ID, 215 | Permission: models.Permission(p), 216 | }) 217 | } 218 | if err = db.Instance.Save(&user).Error; err != nil { 219 | c.JSON(http.StatusInternalServerError, DBError2Response) 220 | return 221 | } 222 | c.JSON(http.StatusOK, UserSaveResponse{ 223 | Token: token, 224 | }) 225 | } 226 | 227 | func UserDelete(c *gin.Context, loggedUser *models.User) { 228 | req := UserInfo{} 229 | err := c.ShouldBindWith(&req, binding.JSON) 230 | if err != nil { 231 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 232 | return 233 | } 234 | if !loggedUser.HasPermission(models.PermissionAdmin) && loggedUser.ID != req.ID { 235 | c.JSON(http.StatusForbidden, NopeResponse) 236 | return 237 | } 238 | user := models.User{ID: req.ID} 239 | if db.Instance.First(&user).Error != nil { 240 | c.JSON(http.StatusBadRequest, Response{"Invalid user"}) 241 | return 242 | } 243 | log.Printf("Will delete user: %d", user.ID) 244 | user.Password = "" 245 | db.Instance.Save(&user) 246 | // TODO: Delete their personal assets later as a background task, keep the group ones 247 | c.JSON(http.StatusOK, OKResponse) 248 | } 249 | 250 | func UserReInvite(c *gin.Context, currentUser *models.User) { 251 | req := UserInfo{} 252 | err := c.ShouldBindWith(&req, binding.JSON) 253 | if err != nil { 254 | c.JSON(http.StatusBadRequest, Response{err.Error()}) 255 | return 256 | } 257 | user := models.User{ID: req.ID} 258 | if user.ID <= 0 { 259 | c.JSON(http.StatusBadRequest, Response{"hmmmm"}) 260 | return 261 | } 262 | if err = db.Instance.Find(&user).Error; err != nil { 263 | c.JSON(http.StatusInternalServerError, DBError1Response) 264 | return 265 | } 266 | user.Email = utils.Rand16BytesToBase62() 267 | user.Password = "" 268 | if err = db.Instance.Save(&user).Error; err != nil { 269 | c.JSON(http.StatusInternalServerError, DBError2Response) 270 | return 271 | } 272 | c.JSON(http.StatusOK, UserSaveResponse{ 273 | Token: user.Email, 274 | }) 275 | } 276 | 277 | func UserGetStatus(c *gin.Context, user *models.User) { 278 | c.JSON(http.StatusOK, newUserStatusResponse(user, true)) 279 | } 280 | 281 | func UserList(c *gin.Context, user *models.User) { 282 | users := []models.User{} 283 | err := db.Instance.Preload("Grants").Order("created_at ASC").Find(&users).Error 284 | if err != nil { 285 | c.JSON(http.StatusInternalServerError, DBError1Response) 286 | return 287 | } 288 | result := []UserInfo{} 289 | for _, u := range users { 290 | bucket := uint64(0) 291 | if u.BucketID != nil { 292 | bucket = *u.BucketID 293 | } 294 | userInfo := UserInfo{ 295 | ID: u.ID, 296 | Name: u.Name, 297 | Email: u.Email, 298 | Bucket: bucket, 299 | Quota: u.Quota, 300 | Permissions: u.GetPermissions(), 301 | } 302 | result = append(result, userInfo) 303 | } 304 | c.JSON(http.StatusOK, result) 305 | } 306 | 307 | func UserLogout(c *gin.Context, user *models.User) { 308 | session := auth.LoadSession(c) 309 | session.LogoutUser() 310 | c.Status(http.StatusNoContent) 311 | } 312 | -------------------------------------------------------------------------------- /handlers/websocket.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "log" 7 | "server/db" 8 | "server/models" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/gin-gonic/gin/binding" 12 | "github.com/gorilla/websocket" 13 | cmap "github.com/orcaman/concurrent-map/v2" 14 | ) 15 | 16 | // sendSocketFunc returns true if data was successfully sent 17 | type sendSocketFunc func([]byte) bool 18 | type connectedClient struct { 19 | sendFunc sendSocketFunc 20 | } 21 | 22 | // connectedClients is needed as a user may be connected more than once 23 | type connectedClients []*connectedClient 24 | 25 | var ( 26 | connectedUsers = cmap.New[connectedClients]() 27 | ) 28 | 29 | func (c *connectedClient) addTo(id string) { 30 | connectedUsers.Upsert(id, connectedClients{c}, func(exist bool, valueInMap, newValue connectedClients) connectedClients { 31 | if exist { 32 | return append(valueInMap, c) 33 | } 34 | return newValue 35 | }) 36 | } 37 | 38 | func (c *connectedClient) removeFrom(id string) { 39 | connectedUsers.Upsert(id, connectedClients{}, func(exist bool, valueInMap, newValue connectedClients) connectedClients { 40 | if !exist { 41 | // TODO: Cleanup this empty arrays 42 | return newValue 43 | } 44 | for _, oc := range valueInMap { 45 | if oc == c { 46 | continue 47 | } 48 | newValue = append(newValue, oc) 49 | } 50 | return newValue 51 | }) 52 | } 53 | 54 | func withMessagesFor(user *models.User, since int64, callback func(models.GroupMessage)) { 55 | rows, err := db.Instance. 56 | Table("group_messages"). 57 | Select("group_messages.id, group_messages.group_id, server_stamp, client_stamp, "+ 58 | "users.id, users.name, content, reply_to, reaction_to"). 59 | Joins("join group_users ON group_users.user_id = ? AND group_users.group_id = group_messages.group_id", user.ID). 60 | Joins("join users ON users.id = group_messages.user_id"). 61 | Where("group_messages.id > ?", since). 62 | Group("group_messages.id"). 63 | Order("group_messages.id ASC"). 64 | Rows() 65 | if err != nil { 66 | return 67 | } 68 | defer rows.Close() 69 | for rows.Next() { 70 | groupMessage := models.GroupMessage{} 71 | if err := rows.Scan(&groupMessage.ID, &groupMessage.GroupID, &groupMessage.ServerStamp, &groupMessage.ClientStamp, 72 | &groupMessage.UserID, &groupMessage.UserName, &groupMessage.Content, &groupMessage.ReplyTo, &groupMessage.ReactionTo); err != nil { 73 | 74 | log.Printf("DB error: %v", err) 75 | continue 76 | } 77 | callback(groupMessage) 78 | } 79 | } 80 | 81 | func WebSocket(c *gin.Context, user *models.User) { 82 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) 83 | if err != nil { 84 | log.Print("upgrade error:", err) 85 | return 86 | } 87 | defer conn.Close() 88 | 89 | // Setup client 90 | isConnected := true 91 | id := models.GetUserSocketID(user.ID) 92 | log.Printf("websocket connected, id: %s", id) 93 | client := connectedClient{} 94 | client.sendFunc = func(data []byte) bool { 95 | if !isConnected { 96 | return false 97 | } 98 | err := conn.WriteMessage(websocket.TextMessage, data) 99 | if err != nil { 100 | log.Println("write err:", err) 101 | isConnected = false 102 | client.removeFrom(id) 103 | return false 104 | } 105 | return true 106 | } 107 | r := MessagesRequest{} 108 | if err = c.ShouldBindWith(&r, binding.Form); err == nil { 109 | message := NewGroupMessage() 110 | withMessagesFor(user, int64(r.SinceID), func(groupMessage models.GroupMessage) { 111 | message.Data = groupMessage 112 | message.Stamp = groupMessage.ServerStamp 113 | buffer := bytes.Buffer{} 114 | _ = json.NewEncoder(&buffer).Encode(message) 115 | client.sendFunc(buffer.Bytes()) 116 | }) 117 | } 118 | client.addTo(id) 119 | defer client.removeFrom(id) 120 | // Main read cycle 121 | for { 122 | mt, message, err := conn.ReadMessage() 123 | if err != nil { 124 | log.Println("read err:", err) 125 | isConnected = false 126 | break 127 | } 128 | if string(message) == "ping" { 129 | conn.WriteMessage(mt, []byte("pong")) 130 | } 131 | if string(message) == "pong" { 132 | continue 133 | } 134 | // log.Printf("recv: %s", message) 135 | processMessage(user, message) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /locations/nominatim.go: -------------------------------------------------------------------------------- 1 | package locations 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "server/models" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var ( 14 | client = http.Client{} 15 | lastRequest = time.Now().Add(-10 * time.Second) 16 | ) 17 | 18 | const ( 19 | throttling = 3 * time.Second 20 | ) 21 | 22 | type NominatimAddress struct { 23 | Aeroway string `json:"aeroway"` 24 | Railway string `json:"railway"` 25 | Place string `json:"place"` 26 | Neighbourhood string `json:"neighbourhood"` 27 | City string `json:"city"` 28 | Municipality string `json:"municipality"` 29 | Province string `json:"province"` 30 | Country string `json:"country"` 31 | CountryCode string `json:"country_code"` 32 | } 33 | 34 | type NominatimLocation struct { 35 | DisplayName string `json:"display_name"` 36 | Address NominatimAddress `json:"address"` 37 | } 38 | 39 | func (n *NominatimLocation) GetCity() string { 40 | if n.Address.City != "" { 41 | return n.Address.City 42 | } 43 | if n.Address.Municipality != "" { 44 | return n.Address.Municipality 45 | } 46 | return n.Address.Province 47 | } 48 | 49 | func (n *NominatimLocation) GetArea() string { 50 | if n.Address.Aeroway != "" && len(n.Address.Aeroway) > 4 { 51 | if n.Address.Neighbourhood != "" { 52 | return n.Address.Aeroway + ", " + n.Address.Neighbourhood 53 | } 54 | return n.Address.Aeroway 55 | } 56 | if n.Address.Railway != "" { 57 | return n.Address.Railway 58 | } 59 | if n.Address.Place != "" { 60 | return n.Address.Place 61 | } 62 | if n.Address.Neighbourhood != "" { 63 | return n.Address.Neighbourhood 64 | } 65 | a := strings.Split(n.DisplayName, ",") 66 | city := n.GetCity() 67 | for i := len(a) - 1; i > 0; i-- { 68 | if strings.TrimLeft(a[i], " ") == city { 69 | return strings.TrimLeft(a[i-1], " ") 70 | } 71 | } 72 | if len(a) == 1 || len(a[0]) >= models.MinLocationDisplaySize { 73 | return a[0] 74 | } 75 | return a[0] + "," + a[1] 76 | } 77 | 78 | func GetNominatimLocation(lat, long float64) *NominatimLocation { 79 | // Add throttling 80 | if time.Since(lastRequest) < throttling { 81 | time.Sleep(throttling - time.Since(lastRequest)) 82 | } 83 | lastRequest = time.Now() 84 | 85 | url := fmt.Sprintf("https://nominatim.openstreetmap.org/reverse?format=json&lat=%f&lon=%f", lat, long) 86 | log.Printf("Making request to: %s", url) 87 | req, _ := http.NewRequest("GET", url, nil) 88 | // TODO: not only English? 89 | req.Header.Set("accept-language", "en") 90 | resp, err := client.Do(req) 91 | if err != nil { 92 | log.Println("Failed request to:", url, err) 93 | return nil 94 | } 95 | result := &NominatimLocation{} 96 | decoder := json.NewDecoder(resp.Body) 97 | defer resp.Body.Close() 98 | 99 | if err = decoder.Decode(result); err != nil { 100 | log.Println(url, err) 101 | return nil 102 | } 103 | return result 104 | } 105 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Nikolay Dimitrov. 2 | // All rights reserved. 3 | // Use of this source code is governed by a MIT style license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "log" 9 | "server/auth" 10 | "server/config" 11 | "server/db" 12 | "server/processing" 13 | "server/utils" 14 | "server/web" 15 | "server/webrtc" 16 | "strings" 17 | "time" 18 | 19 | "server/handlers" 20 | "server/models" 21 | "server/storage" 22 | 23 | "github.com/gin-contrib/cors" 24 | "github.com/gin-contrib/gzip" 25 | "github.com/gin-contrib/sessions" 26 | gormsessions "github.com/gin-contrib/sessions/gorm" 27 | "github.com/gin-gonic/autotls" 28 | "github.com/gin-gonic/gin" 29 | ) 30 | 31 | const ( 32 | sessionStoreKey = "this is a long key" // TODO: convert to env variable 33 | sessionCookieName = "token" 34 | sessionExpirationTime = 365 * 86400 // 1 year 35 | ) 36 | 37 | func main() { 38 | db.Init() 39 | models.Init() 40 | storage.Init() 41 | processing.Init() 42 | go processing.StartProcessing() 43 | 44 | // if !config.DEBUG_MODE { 45 | // gin.SetMode(gin.ReleaseMode) 46 | // } 47 | router := gin.Default() 48 | _ = router.SetTrustedProxies([]string{}) 49 | if config.DEBUG_MODE { 50 | router.Use(utils.ErrorLogMiddleware) 51 | } 52 | router.Use(cors.New(cors.Config{ 53 | AllowOrigins: []string{"*"}, 54 | AllowMethods: []string{"PUT", "POST", "DELETE"}, 55 | AllowHeaders: []string{"Origin"}, 56 | ExposeHeaders: []string{"Content-Length"}, 57 | AllowCredentials: true, 58 | // AllowOriginFunc: func(origin string) bool { 59 | // return strings.HasSuffix(origin, ".circled.me") || strings.HasSuffix(origin, ".circled.me/") 60 | // }, 61 | MaxAge: 30 * 24 * time.Hour, 62 | })) 63 | 64 | // HTML templates 65 | router.LoadHTMLGlob("templates/*.tmpl") 66 | 67 | cookieStore := gormsessions.NewStore(db.Instance, true, []byte(sessionStoreKey)) 68 | cookieStore.Options(sessions.Options{MaxAge: sessionExpirationTime}) 69 | router.Use(sessions.Sessions(sessionCookieName, cookieStore)) 70 | if !config.DEBUG_MODE { 71 | router.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/asset/fetch"}))) 72 | } 73 | router.Use((&utils.CacheRouter{CacheTime: utils.CacheNoCache}).Handler()) // No cache by default, individual end-points can override that 74 | // Custom Auth Router 75 | authRouter := &auth.Router{Base: router} 76 | // Backup handlers 77 | authRouter.POST("/backup/check", handlers.BackupCheck, models.PermissionPhotoUpload, models.PermissionPhotoBackup) 78 | authRouter.PUT("/backup/upload", handlers.BackupUpload, models.PermissionPhotoUpload) 79 | authRouter.POST("/backup/meta-data", handlers.BackupMetaData, models.PermissionPhotoUpload) 80 | authRouter.POST("/backup/confirm", handlers.BackupConfirm, models.PermissionPhotoUpload) 81 | // Bucket handlers 82 | authRouter.GET("/bucket/list", handlers.BucketList, models.PermissionAdmin) 83 | authRouter.POST("/bucket/save", handlers.BucketSave, models.PermissionAdmin) 84 | // User info handlers 85 | router.POST("/user/login", handlers.UserLogin) 86 | authRouter.POST("/user/save", handlers.UserSave, models.PermissionAdmin) 87 | authRouter.POST("/user/delete", handlers.UserDelete) // PermissionAdmin or own account check (in handler) 88 | authRouter.POST("/user/reinvite", handlers.UserReInvite, models.PermissionAdmin) 89 | authRouter.GET("/user/status", handlers.UserGetStatus) 90 | authRouter.GET("/user/list", handlers.UserList) 91 | authRouter.POST("/user/logout", handlers.UserLogout) 92 | // Asset handlers 93 | authRouter.GET("/asset/list", handlers.AssetList, models.PermissionPhotoUpload) 94 | authRouter.GET("/asset/tags", handlers.TagList, models.PermissionPhotoUpload) 95 | authRouter.GET("/asset/fetch", handlers.AssetFetch) // Auth checks are done inside the handler 96 | authRouter.POST("/asset/delete", handlers.AssetDelete, models.PermissionPhotoUpload) // TODO: S3 Delete done? 97 | authRouter.POST("/asset/favourite", handlers.AssetFavourite) 98 | authRouter.POST("/asset/unfavourite", handlers.AssetUnfavourite) 99 | authRouter.GET("/faces/for-asset", handlers.FacesForAsset, models.PermissionPhotoUpload) 100 | authRouter.GET("/faces/people", handlers.PeopleList, models.PermissionPhotoUpload) 101 | authRouter.POST("/faces/create-person", handlers.CreatePerson, models.PermissionPhotoUpload) 102 | authRouter.POST("/faces/assign", handlers.PersonAssignFace, models.PermissionPhotoUpload) 103 | // Album handlers 104 | authRouter.GET("/album/list", handlers.AlbumList) 105 | authRouter.POST("/album/create", handlers.AlbumCreate, models.PermissionPhotoUpload) 106 | authRouter.POST("/album/save", handlers.AlbumSave, models.PermissionPhotoUpload) // TODO: Check hero saved? 107 | authRouter.POST("/album/delete", handlers.AlbumDelete, models.PermissionPhotoUpload) 108 | authRouter.POST("/album/add", handlers.AlbumAddAssets, models.PermissionPhotoUpload) 109 | authRouter.POST("/album/remove", handlers.AlbumRemoveAsset, models.PermissionPhotoUpload) 110 | authRouter.GET("/album/assets", handlers.AlbumAssets) 111 | authRouter.GET("/album/share", handlers.AlbumShare) 112 | authRouter.POST("/album/contributor", handlers.AlbumContributorSave, models.PermissionPhotoUpload) // DEPRECATED 113 | authRouter.GET("/album/contributors", handlers.AlbumContributorsGet, models.PermissionPhotoUpload) 114 | authRouter.POST("/album/contributors", handlers.AlbumContributorsSave, models.PermissionPhotoUpload) 115 | 116 | // Upload Request 117 | authRouter.GET("/upload/share", handlers.UploadShare, models.PermissionPhotoUpload) 118 | // Moment handlers 119 | authRouter.GET("/moment/list", handlers.MomentList, models.PermissionPhotoUpload) 120 | authRouter.GET("/moment/assets", handlers.MomentAssets, models.PermissionPhotoUpload) 121 | // Group handlers 122 | authRouter.GET("/group/list", handlers.GroupList) 123 | authRouter.POST("/group/create", handlers.GroupCreate) 124 | authRouter.POST("/group/save", handlers.GroupSave) 125 | authRouter.POST("/group/delete", handlers.GroupDelete) 126 | // Video Call endpoints 127 | authRouter.GET("/user/video-link", handlers.UserCallLink) // Returns the path to the video call for the current user or creates a new one 128 | authRouter.GET("/group/video-link", handlers.GroupCallLink) // Returns the path to the video call for the given group or creates a new one 129 | router.GET("/call/:id", web.CallView) // Renders the video call page 130 | router.GET("/ws-call/:id", handlers.CallWebSocket) // WebSocket handler for the video call 131 | router.Static("/static", "./static") 132 | 133 | // WebSocket handler 134 | authRouter.GET("/ws", handlers.WebSocket) 135 | 136 | // Web albums 137 | router.GET("/w/album/:token/", web.AlbumView) 138 | router.GET("/w/album/:token/asset", web.AlbumAssetView) 139 | // Web file uploads 140 | router.GET("/w/upload/:token/", web.UploadRequestView) 141 | router.GET("/w/upload/:token/new-url/", web.UploadRequestNewURL) 142 | router.POST("/w/upload/:token/confirm/", web.UploadRequestConfirm) 143 | router.PUT("/w/upload/:token/", web.UploadRequestProcess) 144 | // Misc 145 | router.GET("/robots.txt", web.DisallowRobots) 146 | 147 | // Do we need to start up a TURN server? 148 | var turnServer *webrtc.TurnServer 149 | if config.TURN_SERVER_IP != "" { 150 | turnServer = &webrtc.TurnServer{ 151 | Port: config.TURN_SERVER_PORT, 152 | PublicIP: config.TURN_SERVER_IP, 153 | TrafficMinPort: config.TURN_TRAFFIC_MIN_PORT, 154 | TrafficMaxPort: config.TURN_TRAFFIC_MAX_PORT, 155 | AuthFunc: webrtc.ValidateRoom, 156 | } 157 | if err := turnServer.Start(); err != nil { 158 | log.Printf("Couldn't start TURN server: %v", err) 159 | turnServer = nil 160 | } else { 161 | log.Printf("Started TURN server at %s:%d", config.TURN_SERVER_IP, config.TURN_SERVER_PORT) 162 | } 163 | } else { 164 | log.Println("No TURN server configured") 165 | } 166 | var err error 167 | if config.TLS_DOMAINS != "" { 168 | err = autotls.Run(router, strings.Split(config.TLS_DOMAINS, ",")...) 169 | } else { 170 | err = router.Run(config.BIND_ADDRESS) 171 | } 172 | if turnServer != nil { 173 | turnServer.Stop() 174 | } 175 | log.Fatalf("Server stopped: %v", err) 176 | } 177 | -------------------------------------------------------------------------------- /models/album.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Album struct { 4 | ID uint64 `gorm:"primaryKey"` 5 | UserID uint64 `gorm:"not null;index:user_album_created,priority:1;"` 6 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 7 | HeroAssetID *uint64 8 | HeroAsset Asset `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 9 | CreatedAt int64 `gorm:"index:user_album_created,priority:2"` 10 | Name string `gorm:"type:varchar(300)"` 11 | Hidden bool `gorm:"not null;default 0"` 12 | Contributors []AlbumContributor 13 | } 14 | -------------------------------------------------------------------------------- /models/album_asset.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type AlbumAsset struct { 4 | ID uint64 `gorm:"primaryKey"` // not really needed, but GORM cannot have a foreign key to a composite primary key, so here it is 5 | CreatedAt int64 `gorm:"index:album_order,priority:2"` 6 | AlbumID uint64 `gorm:"index:uniq_album_asset,unique;index:album_order,priority:1"` 7 | Album Album `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 8 | AssetID uint64 `gorm:"index:uniq_album_asset,unique;"` 9 | Asset Asset `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 10 | } 11 | -------------------------------------------------------------------------------- /models/album_contributor.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | ContributorCanAdd = 0 5 | ContributorViewOnly = 1 6 | ) 7 | 8 | type AlbumContributor struct { 9 | CreatedAt int64 10 | UserID uint64 `gorm:"primaryKey"` 11 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 12 | AlbumID uint64 `gorm:"primaryKey"` 13 | Album Album `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 14 | Mode uint8 `gorm:"not null; default 0"` 15 | } 16 | -------------------------------------------------------------------------------- /models/album_share.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "server/utils" 5 | "time" 6 | ) 7 | 8 | type AlbumShare struct { 9 | ID uint64 `gorm:"primaryKey"` 10 | CreatedAt int64 11 | UserID uint64 `gorm:"not null"` 12 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 13 | AlbumID uint64 `gorm:"not null"` 14 | Album Album `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 15 | Token string `gorm:"type:varchar(100);index:uniq_token,unique"` 16 | ExpiresAt int64 `gorm:"not null"` // 0 indicates no expiration 17 | HideOriginal int `gorm:"type:tinyint;not null"` 18 | } 19 | 20 | func NewAlbumShare(userID uint64, album uint64, expires int64, hideOriginal int) AlbumShare { 21 | expiresAt := int64(0) 22 | if expires > 0 { 23 | expiresAt = time.Now().Unix() + expires 24 | } 25 | return AlbumShare{ 26 | UserID: userID, 27 | AlbumID: album, 28 | Token: utils.Rand16BytesToBase62(), 29 | ExpiresAt: expiresAt, 30 | HideOriginal: hideOriginal, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /models/asset.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "server/config" 7 | "server/db" 8 | "server/storage" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/zsefvlol/timezonemapper" 14 | "gorm.io/gorm" 15 | ) 16 | 17 | const ( 18 | AssetTypeOther = 0 19 | AssetTypeImage = 1 20 | AssetTypeVideo = 2 21 | 22 | presignViewURLFor = time.Hour * 24 * 7 23 | presignValidAtLeastFor = time.Minute * 30 24 | ) 25 | 26 | type Asset struct { 27 | ID uint64 `gorm:"primaryKey"` 28 | UserID uint64 `gorm:"index:uniq_remote_id,unique,priority:1;not null;index:user_asset_created,priority:1"` 29 | RemoteID string `gorm:"type:varchar(300);index:uniq_remote_id,unique,priority:2;not null"` 30 | CreatedAt int64 `gorm:"index:user_asset_created,priority:3"` 31 | UpdatedAt int64 32 | Size int64 33 | ThumbSize int64 34 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 35 | GroupID *uint64 // can be null 36 | Group Group `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 37 | BucketID uint64 38 | Bucket storage.Bucket `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 39 | PlaceID *uint64 40 | Place Place `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 41 | Name string `gorm:"type:varchar(300)"` 42 | MimeType string `gorm:"type:varchar(50)"` 43 | GpsLat *float64 `gorm:"type:double"` 44 | GpsLong *float64 `gorm:"type:double"` 45 | TimeOffset *int `gorm:"type:int"` 46 | Favourite bool 47 | Deleted bool `gorm:"index:user_asset_created,priority:2;not null;default 0"` 48 | Width uint16 49 | Height uint16 50 | ThumbWidth uint16 51 | ThumbHeight uint16 52 | Duration uint32 53 | Path string `gorm:"type:varchar(2048)"` // Full path of the asset, including file/object name 54 | ThumbPath string `gorm:"type:varchar(2048)"` // Same but for thumbnail 55 | PresignedUntil int64 56 | PresignedURL string `gorm:"type:varchar(2000)"` 57 | PresignedThumbUntil int64 58 | PresignedThumbURL string `gorm:"type:varchar(2000)"` 59 | } 60 | 61 | // CreatePath returns new path for an asset. For example: 62 | // - group/56/image.jpg 63 | // - user/3/file.xls 64 | func (a *Asset) CreatePath() string { 65 | return a.CreatePathOrThumb(false) 66 | } 67 | 68 | func (a *Asset) CreateThumbPath() string { 69 | return a.CreatePathOrThumb(true) 70 | } 71 | 72 | func (a *Asset) GetCreatedTimeInLocation() time.Time { 73 | if a.GpsLat == nil || a.GpsLong == nil { 74 | return time.Unix(a.CreatedAt, 0) 75 | } 76 | zone, err := time.LoadLocation(timezonemapper.LatLngToTimezoneString(*a.GpsLat, *a.GpsLong)) 77 | if err != nil { 78 | return time.Unix(a.CreatedAt, 0) 79 | } 80 | return time.Unix(a.CreatedAt, 0).In(zone) 81 | } 82 | 83 | func (a *Asset) getAssetFilePathNoExt() string { 84 | result := a.Bucket.AssetPathPattern 85 | if result == "" { 86 | result = config.DEFAULT_ASSET_PATH_PATTERN 87 | } 88 | assetTime := a.GetCreatedTimeInLocation() 89 | ext := filepath.Ext(a.Name) 90 | name := a.Name[:len(a.Name)-len(ext)] // remove extension 91 | result = strings.ReplaceAll(result, "", strconv.FormatUint(a.ID, 10)) 92 | result = strings.ReplaceAll(result, "", name) 93 | result = strings.ReplaceAll(result, "", strconv.Itoa(assetTime.Year())) 94 | result = strings.ReplaceAll(result, "", fmt.Sprintf("%02d", assetTime.Month())) 95 | result = strings.ReplaceAll(result, "", assetTime.Month().String()) 96 | return result 97 | } 98 | 99 | func (a *Asset) CreatePathOrThumb(thumb bool) string { 100 | subDir := "" 101 | if a.GroupID != nil { 102 | // This is an asset uploaded to a Group (as part of a Post) 103 | subDir = "group/" + strconv.FormatUint(*a.GroupID, 10) 104 | } else { 105 | // It must be an asset for a User (private or part of Post on their "wall") 106 | subDir = "user/" + strconv.FormatUint(a.UserID, 10) 107 | } 108 | path := subDir + "/" + a.getAssetFilePathNoExt() 109 | // Add extension 110 | if thumb { 111 | // Thumbs are always JPEG 112 | path += "_thumb.jpg" 113 | } else { 114 | path += strings.ToLower(filepath.Ext(a.Name)) 115 | } 116 | return path 117 | } 118 | 119 | func (a *Asset) GetPathOrThumb(thumb bool) string { 120 | if thumb { 121 | if a.ThumbPath == "" { 122 | a.ThumbPath = a.CreateThumbPath() 123 | } 124 | return a.ThumbPath 125 | } 126 | if a.Path == "" { 127 | a.Path = a.CreatePath() 128 | } 129 | return a.Path 130 | } 131 | 132 | func (a *Asset) BeforeSave(tx *gorm.DB) (err error) { 133 | // Restrict the characters in Name 134 | var name strings.Builder 135 | for i, c := range a.Name { 136 | if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || 137 | (c == '.' && i > 0) || (c == '-') || (c == '_') { 138 | 139 | name.WriteRune(c) 140 | } else { 141 | // Replace all other characters with '_' (underscore) 142 | name.WriteString("_") 143 | } 144 | } 145 | a.Name = name.String() 146 | return 147 | } 148 | 149 | func (a *Asset) IsVideo() bool { 150 | return strings.HasPrefix(strings.ToLower(a.MimeType), "video/") 151 | } 152 | 153 | func (a *Asset) GetRoughLocation() (location Location) { 154 | if a.GpsLat != nil && a.GpsLong != nil { 155 | // Truncate - only use 0.0001 of precision 156 | location.GpsLat = float64(int(*a.GpsLat*10000)) / 10000 157 | location.GpsLong = float64(int(*a.GpsLong*10000)) / 10000 158 | } 159 | return 160 | } 161 | 162 | // CreateUploadURI creates a URI that is then to be called by the App 163 | // The URI could be either: 164 | // 1. local (i.e. starting with /..) 165 | // 2. Pre-signed remote S3 upload URL 166 | // 167 | // TODO: Add error response 168 | func (a *Asset) CreateUploadURI(thumb bool, webToken string) string { 169 | // TODO: Better way? 170 | if a.Bucket.ID != a.BucketID { 171 | db.Instance.Preload("Bucket").First(a) 172 | } 173 | if a.Bucket.IsS3() { 174 | return a.Bucket.CreateS3UploadURI(a.GetPathOrThumb(thumb)) 175 | } 176 | if webToken != "" { 177 | return "/w/upload/" + webToken + "/?id=" + strconv.FormatUint(a.ID, 10) + "&thumb=" + strconv.FormatBool(thumb) 178 | } 179 | return "/backup/upload?id=" + strconv.FormatUint(a.ID, 10) + "&thumb=" + strconv.FormatBool(thumb) 180 | } 181 | 182 | // NOTE: a.Bucket must be preloaded 183 | func (a *Asset) GetS3DownloadURL(thumb bool) (string, int64) { 184 | // Separatel fields for thumb... 185 | if thumb && a.ThumbSize > 0 { 186 | if a.PresignedThumbURL == "" || a.PresignedThumbUntil < time.Now().Add(presignValidAtLeastFor).Unix() { 187 | // Need to sign again.. 188 | a.PresignedThumbURL = a.Bucket.CreateS3DownloadURI(a.ThumbPath, presignViewURLFor) 189 | a.PresignedThumbUntil = time.Now().Add(presignViewURLFor).Unix() 190 | db.Instance.Updates(a) 191 | } 192 | return a.PresignedThumbURL, a.PresignedThumbUntil 193 | } 194 | 195 | // Valid at least for another 30 minutes? 196 | if a.PresignedURL == "" || a.PresignedUntil < time.Now().Add(presignValidAtLeastFor).Unix() { 197 | // Need to sign again.. 198 | a.PresignedURL = a.Bucket.CreateS3DownloadURI(a.Path, presignViewURLFor) 199 | a.PresignedUntil = time.Now().Add(presignViewURLFor).Unix() 200 | db.Instance.Updates(a) 201 | } 202 | return a.PresignedURL, a.PresignedUntil 203 | } 204 | -------------------------------------------------------------------------------- /models/asset_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "reflect" 5 | "server/storage" 6 | "testing" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | ) 11 | 12 | func TestAsset_GetCreatedTimeInLocation(t *testing.T) { 13 | type fields struct { 14 | ID uint64 15 | UserID uint64 16 | RemoteID string 17 | CreatedAt int64 18 | UpdatedAt int64 19 | Size int64 20 | ThumbSize int64 21 | User User 22 | GroupID *uint64 23 | Group Group 24 | BucketID uint64 25 | Bucket storage.Bucket 26 | PlaceID *uint64 27 | Place Place 28 | Name string 29 | MimeType string 30 | GpsLat *float64 31 | GpsLong *float64 32 | Favourite bool 33 | Deleted bool 34 | Width uint16 35 | Height uint16 36 | ThumbWidth uint16 37 | ThumbHeight uint16 38 | Duration uint32 39 | Path string 40 | ThumbPath string 41 | PresignedUntil int64 42 | PresignedURL string 43 | PresignedThumbUntil int64 44 | PresignedThumbURL string 45 | } 46 | CST, _ := time.LoadLocation("Asia/Shanghai") 47 | tests := []struct { 48 | name string 49 | fields fields 50 | want time.Time 51 | }{ 52 | { 53 | name: "Asia/Shanghai", // CST 54 | fields: fields{ 55 | CreatedAt: 1696258800, 56 | GpsLat: aws.Float64(39.9254474), 57 | GpsLong: aws.Float64(116.3870752), 58 | }, 59 | want: time.Unix(1696258800, 0).Local().In(CST), 60 | }, 61 | { 62 | name: "Local", // when no GPS coords 63 | fields: fields{ 64 | CreatedAt: 1696258800, 65 | }, 66 | want: time.Unix(1696258800, 0), 67 | }, 68 | } 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | a := &Asset{ 72 | ID: tt.fields.ID, 73 | UserID: tt.fields.UserID, 74 | RemoteID: tt.fields.RemoteID, 75 | CreatedAt: tt.fields.CreatedAt, 76 | UpdatedAt: tt.fields.UpdatedAt, 77 | Size: tt.fields.Size, 78 | ThumbSize: tt.fields.ThumbSize, 79 | User: tt.fields.User, 80 | GroupID: tt.fields.GroupID, 81 | Group: tt.fields.Group, 82 | BucketID: tt.fields.BucketID, 83 | Bucket: tt.fields.Bucket, 84 | PlaceID: tt.fields.PlaceID, 85 | Place: tt.fields.Place, 86 | Name: tt.fields.Name, 87 | MimeType: tt.fields.MimeType, 88 | GpsLat: tt.fields.GpsLat, 89 | GpsLong: tt.fields.GpsLong, 90 | Favourite: tt.fields.Favourite, 91 | Deleted: tt.fields.Deleted, 92 | Width: tt.fields.Width, 93 | Height: tt.fields.Height, 94 | ThumbWidth: tt.fields.ThumbWidth, 95 | ThumbHeight: tt.fields.ThumbHeight, 96 | Duration: tt.fields.Duration, 97 | Path: tt.fields.Path, 98 | ThumbPath: tt.fields.ThumbPath, 99 | PresignedUntil: tt.fields.PresignedUntil, 100 | PresignedURL: tt.fields.PresignedURL, 101 | PresignedThumbUntil: tt.fields.PresignedThumbUntil, 102 | PresignedThumbURL: tt.fields.PresignedThumbURL, 103 | } 104 | if got := a.GetCreatedTimeInLocation(); !reflect.DeepEqual(got, tt.want) { 105 | t.Errorf("Asset.GetCreatedTimeInLocation() = %v, want %v", got, tt.want) 106 | } 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /models/face.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | FacesVectorDistance = "(t2.v0-t1.v0)*(t2.v0-t1.v0) + (t2.v1-t1.v1)*(t2.v1-t1.v1) + (t2.v2-t1.v2)*(t2.v2-t1.v2) + (t2.v3-t1.v3)*(t2.v3-t1.v3) + (t2.v4-t1.v4)*(t2.v4-t1.v4) + (t2.v5-t1.v5)*(t2.v5-t1.v5) + (t2.v6-t1.v6)*(t2.v6-t1.v6) + (t2.v7-t1.v7)*(t2.v7-t1.v7) + (t2.v8-t1.v8)*(t2.v8-t1.v8) + (t2.v9-t1.v9)*(t2.v9-t1.v9) + (t2.v10-t1.v10)*(t2.v10-t1.v10) + (t2.v11-t1.v11)*(t2.v11-t1.v11) + (t2.v12-t1.v12)*(t2.v12-t1.v12) + (t2.v13-t1.v13)*(t2.v13-t1.v13) + (t2.v14-t1.v14)*(t2.v14-t1.v14) + (t2.v15-t1.v15)*(t2.v15-t1.v15) + (t2.v16-t1.v16)*(t2.v16-t1.v16) + (t2.v17-t1.v17)*(t2.v17-t1.v17) + (t2.v18-t1.v18)*(t2.v18-t1.v18) + (t2.v19-t1.v19)*(t2.v19-t1.v19) + (t2.v20-t1.v20)*(t2.v20-t1.v20) + (t2.v21-t1.v21)*(t2.v21-t1.v21) + (t2.v22-t1.v22)*(t2.v22-t1.v22) + (t2.v23-t1.v23)*(t2.v23-t1.v23) + (t2.v24-t1.v24)*(t2.v24-t1.v24) + (t2.v25-t1.v25)*(t2.v25-t1.v25) + (t2.v26-t1.v26)*(t2.v26-t1.v26) + (t2.v27-t1.v27)*(t2.v27-t1.v27) + (t2.v28-t1.v28)*(t2.v28-t1.v28) + (t2.v29-t1.v29)*(t2.v29-t1.v29) + (t2.v30-t1.v30)*(t2.v30-t1.v30) + (t2.v31-t1.v31)*(t2.v31-t1.v31) + (t2.v32-t1.v32)*(t2.v32-t1.v32) + (t2.v33-t1.v33)*(t2.v33-t1.v33) + (t2.v34-t1.v34)*(t2.v34-t1.v34) + (t2.v35-t1.v35)*(t2.v35-t1.v35) + (t2.v36-t1.v36)*(t2.v36-t1.v36) + (t2.v37-t1.v37)*(t2.v37-t1.v37) + (t2.v38-t1.v38)*(t2.v38-t1.v38) + (t2.v39-t1.v39)*(t2.v39-t1.v39) + (t2.v40-t1.v40)*(t2.v40-t1.v40) + (t2.v41-t1.v41)*(t2.v41-t1.v41) + (t2.v42-t1.v42)*(t2.v42-t1.v42) + (t2.v43-t1.v43)*(t2.v43-t1.v43) + (t2.v44-t1.v44)*(t2.v44-t1.v44) + (t2.v45-t1.v45)*(t2.v45-t1.v45) + (t2.v46-t1.v46)*(t2.v46-t1.v46) + (t2.v47-t1.v47)*(t2.v47-t1.v47) + (t2.v48-t1.v48)*(t2.v48-t1.v48) + (t2.v49-t1.v49)*(t2.v49-t1.v49) + (t2.v50-t1.v50)*(t2.v50-t1.v50) + (t2.v51-t1.v51)*(t2.v51-t1.v51) + (t2.v52-t1.v52)*(t2.v52-t1.v52) + (t2.v53-t1.v53)*(t2.v53-t1.v53) + (t2.v54-t1.v54)*(t2.v54-t1.v54) + (t2.v55-t1.v55)*(t2.v55-t1.v55) + (t2.v56-t1.v56)*(t2.v56-t1.v56) + (t2.v57-t1.v57)*(t2.v57-t1.v57) + (t2.v58-t1.v58)*(t2.v58-t1.v58) + (t2.v59-t1.v59)*(t2.v59-t1.v59) + (t2.v60-t1.v60)*(t2.v60-t1.v60) + (t2.v61-t1.v61)*(t2.v61-t1.v61) + (t2.v62-t1.v62)*(t2.v62-t1.v62) + (t2.v63-t1.v63)*(t2.v63-t1.v63) + (t2.v64-t1.v64)*(t2.v64-t1.v64) + (t2.v65-t1.v65)*(t2.v65-t1.v65) + (t2.v66-t1.v66)*(t2.v66-t1.v66) + (t2.v67-t1.v67)*(t2.v67-t1.v67) + (t2.v68-t1.v68)*(t2.v68-t1.v68) + (t2.v69-t1.v69)*(t2.v69-t1.v69) + (t2.v70-t1.v70)*(t2.v70-t1.v70) + (t2.v71-t1.v71)*(t2.v71-t1.v71) + (t2.v72-t1.v72)*(t2.v72-t1.v72) + (t2.v73-t1.v73)*(t2.v73-t1.v73) + (t2.v74-t1.v74)*(t2.v74-t1.v74) + (t2.v75-t1.v75)*(t2.v75-t1.v75) + (t2.v76-t1.v76)*(t2.v76-t1.v76) + (t2.v77-t1.v77)*(t2.v77-t1.v77) + (t2.v78-t1.v78)*(t2.v78-t1.v78) + (t2.v79-t1.v79)*(t2.v79-t1.v79) + (t2.v80-t1.v80)*(t2.v80-t1.v80) + (t2.v81-t1.v81)*(t2.v81-t1.v81) + (t2.v82-t1.v82)*(t2.v82-t1.v82) + (t2.v83-t1.v83)*(t2.v83-t1.v83) + (t2.v84-t1.v84)*(t2.v84-t1.v84) + (t2.v85-t1.v85)*(t2.v85-t1.v85) + (t2.v86-t1.v86)*(t2.v86-t1.v86) + (t2.v87-t1.v87)*(t2.v87-t1.v87) + (t2.v88-t1.v88)*(t2.v88-t1.v88) + (t2.v89-t1.v89)*(t2.v89-t1.v89) + (t2.v90-t1.v90)*(t2.v90-t1.v90) + (t2.v91-t1.v91)*(t2.v91-t1.v91) + (t2.v92-t1.v92)*(t2.v92-t1.v92) + (t2.v93-t1.v93)*(t2.v93-t1.v93) + (t2.v94-t1.v94)*(t2.v94-t1.v94) + (t2.v95-t1.v95)*(t2.v95-t1.v95) + (t2.v96-t1.v96)*(t2.v96-t1.v96) + (t2.v97-t1.v97)*(t2.v97-t1.v97) + (t2.v98-t1.v98)*(t2.v98-t1.v98) + (t2.v99-t1.v99)*(t2.v99-t1.v99) + (t2.v100-t1.v100)*(t2.v100-t1.v100) + (t2.v101-t1.v101)*(t2.v101-t1.v101) + (t2.v102-t1.v102)*(t2.v102-t1.v102) + (t2.v103-t1.v103)*(t2.v103-t1.v103) + (t2.v104-t1.v104)*(t2.v104-t1.v104) + (t2.v105-t1.v105)*(t2.v105-t1.v105) + (t2.v106-t1.v106)*(t2.v106-t1.v106) + (t2.v107-t1.v107)*(t2.v107-t1.v107) + (t2.v108-t1.v108)*(t2.v108-t1.v108) + (t2.v109-t1.v109)*(t2.v109-t1.v109) + (t2.v110-t1.v110)*(t2.v110-t1.v110) + (t2.v111-t1.v111)*(t2.v111-t1.v111) + (t2.v112-t1.v112)*(t2.v112-t1.v112) + (t2.v113-t1.v113)*(t2.v113-t1.v113) + (t2.v114-t1.v114)*(t2.v114-t1.v114) + (t2.v115-t1.v115)*(t2.v115-t1.v115) + (t2.v116-t1.v116)*(t2.v116-t1.v116) + (t2.v117-t1.v117)*(t2.v117-t1.v117) + (t2.v118-t1.v118)*(t2.v118-t1.v118) + (t2.v119-t1.v119)*(t2.v119-t1.v119) + (t2.v120-t1.v120)*(t2.v120-t1.v120) + (t2.v121-t1.v121)*(t2.v121-t1.v121) + (t2.v122-t1.v122)*(t2.v122-t1.v122) + (t2.v123-t1.v123)*(t2.v123-t1.v123) + (t2.v124-t1.v124)*(t2.v124-t1.v124) + (t2.v125-t1.v125)*(t2.v125-t1.v125) + (t2.v126-t1.v126)*(t2.v126-t1.v126) + (t2.v127-t1.v127)*(t2.v127-t1.v127)" 5 | ) 6 | 7 | type Face struct { 8 | ID uint64 `gorm:"primaryKey"` 9 | UserID uint64 `gorm:"index:user_index,priority:1"` 10 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 11 | CreatedAt int64 `gorm:"index:user_index,priority:2"` 12 | AssetID uint64 `gorm:"index:uniq_asset_face,unique;priority:1"` 13 | Asset Asset `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 14 | PersonID *uint64 `gorm:""` 15 | Person Person `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 16 | Distance float64 `gorm:"type:double"` 17 | Num int `gorm:"index:uniq_asset_face,unique;"` 18 | X1 int `gorm:"type:int"` 19 | Y1 int `gorm:"type:int"` 20 | X2 int `gorm:"type:int"` 21 | Y2 int `gorm:"type:int"` 22 | V0 float32 `gorm:"type:double"` 23 | V1 float32 `gorm:"type:double"` 24 | V2 float32 `gorm:"type:double"` 25 | V3 float32 `gorm:"type:double"` 26 | V4 float32 `gorm:"type:double"` 27 | V5 float32 `gorm:"type:double"` 28 | V6 float32 `gorm:"type:double"` 29 | V7 float32 `gorm:"type:double"` 30 | V8 float32 `gorm:"type:double"` 31 | V9 float32 `gorm:"type:double"` 32 | V10 float32 `gorm:"type:double"` 33 | V11 float32 `gorm:"type:double"` 34 | V12 float32 `gorm:"type:double"` 35 | V13 float32 `gorm:"type:double"` 36 | V14 float32 `gorm:"type:double"` 37 | V15 float32 `gorm:"type:double"` 38 | V16 float32 `gorm:"type:double"` 39 | V17 float32 `gorm:"type:double"` 40 | V18 float32 `gorm:"type:double"` 41 | V19 float32 `gorm:"type:double"` 42 | V20 float32 `gorm:"type:double"` 43 | V21 float32 `gorm:"type:double"` 44 | V22 float32 `gorm:"type:double"` 45 | V23 float32 `gorm:"type:double"` 46 | V24 float32 `gorm:"type:double"` 47 | V25 float32 `gorm:"type:double"` 48 | V26 float32 `gorm:"type:double"` 49 | V27 float32 `gorm:"type:double"` 50 | V28 float32 `gorm:"type:double"` 51 | V29 float32 `gorm:"type:double"` 52 | V30 float32 `gorm:"type:double"` 53 | V31 float32 `gorm:"type:double"` 54 | V32 float32 `gorm:"type:double"` 55 | V33 float32 `gorm:"type:double"` 56 | V34 float32 `gorm:"type:double"` 57 | V35 float32 `gorm:"type:double"` 58 | V36 float32 `gorm:"type:double"` 59 | V37 float32 `gorm:"type:double"` 60 | V38 float32 `gorm:"type:double"` 61 | V39 float32 `gorm:"type:double"` 62 | V40 float32 `gorm:"type:double"` 63 | V41 float32 `gorm:"type:double"` 64 | V42 float32 `gorm:"type:double"` 65 | V43 float32 `gorm:"type:double"` 66 | V44 float32 `gorm:"type:double"` 67 | V45 float32 `gorm:"type:double"` 68 | V46 float32 `gorm:"type:double"` 69 | V47 float32 `gorm:"type:double"` 70 | V48 float32 `gorm:"type:double"` 71 | V49 float32 `gorm:"type:double"` 72 | V50 float32 `gorm:"type:double"` 73 | V51 float32 `gorm:"type:double"` 74 | V52 float32 `gorm:"type:double"` 75 | V53 float32 `gorm:"type:double"` 76 | V54 float32 `gorm:"type:double"` 77 | V55 float32 `gorm:"type:double"` 78 | V56 float32 `gorm:"type:double"` 79 | V57 float32 `gorm:"type:double"` 80 | V58 float32 `gorm:"type:double"` 81 | V59 float32 `gorm:"type:double"` 82 | V60 float32 `gorm:"type:double"` 83 | V61 float32 `gorm:"type:double"` 84 | V62 float32 `gorm:"type:double"` 85 | V63 float32 `gorm:"type:double"` 86 | V64 float32 `gorm:"type:double"` 87 | V65 float32 `gorm:"type:double"` 88 | V66 float32 `gorm:"type:double"` 89 | V67 float32 `gorm:"type:double"` 90 | V68 float32 `gorm:"type:double"` 91 | V69 float32 `gorm:"type:double"` 92 | V70 float32 `gorm:"type:double"` 93 | V71 float32 `gorm:"type:double"` 94 | V72 float32 `gorm:"type:double"` 95 | V73 float32 `gorm:"type:double"` 96 | V74 float32 `gorm:"type:double"` 97 | V75 float32 `gorm:"type:double"` 98 | V76 float32 `gorm:"type:double"` 99 | V77 float32 `gorm:"type:double"` 100 | V78 float32 `gorm:"type:double"` 101 | V79 float32 `gorm:"type:double"` 102 | V80 float32 `gorm:"type:double"` 103 | V81 float32 `gorm:"type:double"` 104 | V82 float32 `gorm:"type:double"` 105 | V83 float32 `gorm:"type:double"` 106 | V84 float32 `gorm:"type:double"` 107 | V85 float32 `gorm:"type:double"` 108 | V86 float32 `gorm:"type:double"` 109 | V87 float32 `gorm:"type:double"` 110 | V88 float32 `gorm:"type:double"` 111 | V89 float32 `gorm:"type:double"` 112 | V90 float32 `gorm:"type:double"` 113 | V91 float32 `gorm:"type:double"` 114 | V92 float32 `gorm:"type:double"` 115 | V93 float32 `gorm:"type:double"` 116 | V94 float32 `gorm:"type:double"` 117 | V95 float32 `gorm:"type:double"` 118 | V96 float32 `gorm:"type:double"` 119 | V97 float32 `gorm:"type:double"` 120 | V98 float32 `gorm:"type:double"` 121 | V99 float32 `gorm:"type:double"` 122 | V100 float32 `gorm:"type:double"` 123 | V101 float32 `gorm:"type:double"` 124 | V102 float32 `gorm:"type:double"` 125 | V103 float32 `gorm:"type:double"` 126 | V104 float32 `gorm:"type:double"` 127 | V105 float32 `gorm:"type:double"` 128 | V106 float32 `gorm:"type:double"` 129 | V107 float32 `gorm:"type:double"` 130 | V108 float32 `gorm:"type:double"` 131 | V109 float32 `gorm:"type:double"` 132 | V110 float32 `gorm:"type:double"` 133 | V111 float32 `gorm:"type:double"` 134 | V112 float32 `gorm:"type:double"` 135 | V113 float32 `gorm:"type:double"` 136 | V114 float32 `gorm:"type:double"` 137 | V115 float32 `gorm:"type:double"` 138 | V116 float32 `gorm:"type:double"` 139 | V117 float32 `gorm:"type:double"` 140 | V118 float32 `gorm:"type:double"` 141 | V119 float32 `gorm:"type:double"` 142 | V120 float32 `gorm:"type:double"` 143 | V121 float32 `gorm:"type:double"` 144 | V122 float32 `gorm:"type:double"` 145 | V123 float32 `gorm:"type:double"` 146 | V124 float32 `gorm:"type:double"` 147 | V125 float32 `gorm:"type:double"` 148 | V126 float32 `gorm:"type:double"` 149 | V127 float32 `gorm:"type:double"` 150 | } 151 | -------------------------------------------------------------------------------- /models/favourite_asset.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type FavouriteAsset struct { 4 | UserID uint64 `gorm:"primaryKey"` 5 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 6 | AssetID uint64 `gorm:"primaryKey"` 7 | Asset Asset `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 8 | AlbumAssetID *uint64 `gorm:"null;default null"` 9 | AlbumAsset AlbumAsset `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` 10 | } 11 | -------------------------------------------------------------------------------- /models/grant.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Permission uint8 4 | 5 | const ( 6 | PermissionNone Permission = 0 7 | PermissionAdmin Permission = 1 8 | PermissionPhotoUpload Permission = 2 9 | PermissionCanCreateGroups Permission = 3 10 | PermissionPhotoBackup Permission = 5 11 | ) 12 | 13 | var ( 14 | AllPermissions = []Permission{ 15 | PermissionAdmin, 16 | PermissionPhotoUpload, 17 | PermissionCanCreateGroups, 18 | PermissionPhotoBackup, 19 | } 20 | ) 21 | 22 | type Grant struct { 23 | ID uint64 `gorm:"primaryKey"` 24 | CreatedAt int64 25 | GrantorID uint64 26 | Grantor User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 27 | UserID uint64 `gorm:"index:user_permission,unique"` 28 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 29 | Permission Permission `gorm:"index:user_permission,unique"` 30 | } 31 | -------------------------------------------------------------------------------- /models/group.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "server/db" 4 | 5 | type Group struct { 6 | ID uint64 `gorm:"primaryKey"` 7 | CreatedAt int64 8 | UpdatedAt int64 9 | CreatedByID uint64 10 | CreatedBy User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 11 | Name string `gorm:"type:varchar(300)"` 12 | Members []GroupUser `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 13 | } 14 | 15 | func LoadGroupUserIDs(groupID uint64) map[uint64]string { 16 | result := map[uint64]string{} 17 | rows, err := db.Instance. 18 | Table("group_users"). 19 | Joins("join users on users.id=user_id"). 20 | Select("user_id, push_token"). 21 | Where("group_id = ?", groupID). 22 | Rows() 23 | if err != nil { 24 | return result 25 | } 26 | id := uint64(0) 27 | token := "" 28 | for rows.Next() { 29 | if err = rows.Scan(&id, &token); err != nil { 30 | continue 31 | } 32 | result[id] = token 33 | } 34 | rows.Close() 35 | return result 36 | } 37 | 38 | func GetGroupRecipients(groupID uint64, initiator *User) map[uint64]string { 39 | recipients := LoadGroupUserIDs(groupID) 40 | if _, ok := recipients[initiator.ID]; !ok { 41 | return map[uint64]string{} 42 | } 43 | return recipients 44 | } 45 | -------------------------------------------------------------------------------- /models/group_message.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type GroupMessage struct { 4 | ID uint64 `gorm:"primaryKey;index:user_urder,priority:2" json:"id"` 5 | GroupID uint64 `gorm:"index:group_order,unique,priority:1" json:"group_id"` 6 | ServerStamp int64 `gorm:"index:group_order,unique,priority:2" json:"server_stamp"` 7 | ClientStamp int64 `json:"client_stamp"` 8 | Group Group `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 9 | UserID uint64 `gorm:"index:group_order,unique,priority:3;index:user_urder,priority:1" json:"user_id"` 10 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 11 | UserName string `gorm:"-" json:"user_name"` 12 | Content string `gorm:"type:varchar(5000)" json:"content"` 13 | ReplyTo uint64 `gorm:"not null;default 0" json:"reply_to"` 14 | ReactionTo uint64 `gorm:"not null;default 0" json:"reaction_to"` 15 | } 16 | -------------------------------------------------------------------------------- /models/group_user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type GroupUser struct { 4 | CreatedAt int64 `gorm:"index"` 5 | GroupID uint64 `gorm:"primaryKey"` 6 | Group Group `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 7 | UserID uint64 `gorm:"primaryKey"` 8 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 9 | SeenMessage uint64 `gorm:"not null;default:0"` 10 | IsAdmin bool 11 | IsFavourite bool 12 | Colour string `gorm:"type:varchar(10);not null"` 13 | } 14 | -------------------------------------------------------------------------------- /models/init.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "server/db" 7 | "time" 8 | ) 9 | 10 | func Init() { 11 | // Seed the random number generator - required for User.Salt 12 | rand.Seed(time.Now().UnixNano()) 13 | 14 | es := []error{} 15 | es = append(es, db.Instance.AutoMigrate(&Album{})) 16 | es = append(es, db.Instance.AutoMigrate(&AlbumAsset{})) 17 | es = append(es, db.Instance.AutoMigrate(&AlbumContributor{})) 18 | es = append(es, db.Instance.AutoMigrate(&AlbumAsset{})) 19 | es = append(es, db.Instance.AutoMigrate(&AlbumShare{})) 20 | es = append(es, db.Instance.AutoMigrate(&Asset{})) 21 | es = append(es, db.Instance.AutoMigrate(&Face{})) 22 | es = append(es, db.Instance.AutoMigrate(&FavouriteAsset{})) 23 | es = append(es, db.Instance.AutoMigrate(&Grant{})) 24 | es = append(es, db.Instance.AutoMigrate(&Group{})) 25 | es = append(es, db.Instance.AutoMigrate(&GroupMessage{})) 26 | es = append(es, db.Instance.AutoMigrate(&GroupUser{})) 27 | es = append(es, db.Instance.AutoMigrate(&Location{})) 28 | es = append(es, db.Instance.AutoMigrate(&Place{})) 29 | es = append(es, db.Instance.AutoMigrate(&Person{})) 30 | es = append(es, db.Instance.AutoMigrate(&UploadRequest{})) 31 | es = append(es, db.Instance.AutoMigrate(&User{})) 32 | es = append(es, db.Instance.AutoMigrate(&VideoCall{})) 33 | 34 | for _, e := range es { 35 | if e != nil { 36 | log.Printf("Auto-migrate error: %v", e) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /models/location.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "server/db" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | MinLocationDisplaySize = 5 10 | ) 11 | 12 | // Location is used as cache to avoid hammering the Geocoding service 13 | type Location struct { 14 | GpsLat float64 `gorm:"type:double;primaryKey"` // Rounded to 0.0001 15 | GpsLong float64 `gorm:"type:double;primaryKey"` // Rounded to 0.0001 16 | Display string `gorm:"type:varchar(250)"` 17 | Area string `gorm:"type:varchar(100)"` 18 | City string `gorm:"type:varchar(100)"` 19 | Country string `gorm:"type:varchar(100)"` 20 | CountryCode string `gorm:"type:varchar(10)"` 21 | } 22 | 23 | func (n *Location) GetShortDisplay() string { 24 | r := strings.SplitN(n.Display, ",", 3) 25 | if len(r) == 1 || len(r[0]) >= MinLocationDisplaySize { 26 | return r[0] 27 | } 28 | return r[0] + "," + r[1] 29 | } 30 | 31 | func (location *Location) GetPlaceID() uint64 { 32 | place := Place{ 33 | Area: location.Area, 34 | City: location.City, 35 | Country: location.Country, 36 | } 37 | db.Instance.Where(&place, "area", "city", "country").FirstOrCreate(&place) 38 | return place.ID 39 | } 40 | -------------------------------------------------------------------------------- /models/person.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Person struct { 4 | ID uint64 `gorm:"primaryKey"` 5 | CreatedAt int64 `gorm:""` 6 | UserID uint64 `gorm:"index:uniq_user_person,unique;priority:1"` 7 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 8 | Name string `gorm:"type:varchar(300);index:uniq_user_person,unique;priority:2"` 9 | } 10 | 11 | // TableName overrides the table name 12 | func (Person) TableName() string { 13 | return "people" 14 | } 15 | -------------------------------------------------------------------------------- /models/place.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Place struct { 4 | ID uint64 `gorm:"primaryKey"` 5 | Area string `gorm:"type:varchar(100);index:uniq_place,unique,priority:3;not null"` 6 | City string `gorm:"type:varchar(100);index:uniq_place,unique,priority:2;not null"` 7 | Country string `gorm:"type:varchar(100);index:uniq_place,unique,priority:1;not null"` 8 | } 9 | -------------------------------------------------------------------------------- /models/upload_request.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "server/utils" 4 | 5 | type UploadRequest struct { 6 | ID uint64 `gorm:"primaryKey"` 7 | CreatedAt int64 8 | FixedIP string 9 | UserID uint64 `gorm:"not null"` 10 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 11 | Token string `gorm:"type:varchar(100);index:uniq_token,unique"` 12 | } 13 | 14 | func NewUploadRequest(userID uint64) UploadRequest { 15 | return UploadRequest{ 16 | UserID: userID, 17 | Token: utils.Rand16BytesToBase62(), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "server/db" 5 | "server/storage" 6 | "server/utils" 7 | "strconv" 8 | ) 9 | 10 | type User struct { 11 | ID uint64 `gorm:"primaryKey"` 12 | CreatedAt int 13 | UpdatedAt int 14 | LastSeen uint64 `gorm:"not null"` 15 | CreatedByID *uint64 16 | CreatedBy *User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 17 | Name string `gorm:"type:varchar(100)"` 18 | Email string `gorm:"type:varchar(150);index:uniq_email,unique"` // TODO: rename Email to Login 19 | Password string `gorm:"type:varchar(128)"` 20 | PassSalt string `gorm:"type:varchar(200)"` 21 | Grants []Grant `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 22 | BucketID *uint64 23 | Bucket storage.Bucket `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` 24 | PushToken string `gorm:"type:varchar(128)"` 25 | 26 | // Settings 27 | Quota int64 `gorm:"not null"` // in MB 28 | VideoSetting uint8 `gorm:"not null"` 29 | // ImageProcessing uint8 `gorm:"not null"` // 0 - no, 1 - always to JPEG 30 | } 31 | 32 | const ( 33 | saltSize = 60 34 | 35 | VideoSettingConvert = 0 36 | VideoSettingSkip = 1 37 | ) 38 | 39 | func UserCreate(name, email, plainTextPassword string) (u User, err error) { 40 | // TODO: Be able to pass different storage bucket 41 | storage := storage.GetDefaultStorage() 42 | 43 | u.Email = email 44 | u.Name = name 45 | u.PassSalt = utils.RandSalt(saltSize) 46 | u.Password = utils.Sha512String(plainTextPassword + u.PassSalt) 47 | if storage != nil { 48 | u.BucketID = &storage.GetBucket().ID 49 | } 50 | return u, db.Instance.Create(&u).Error 51 | } 52 | 53 | func (u *User) SetNewPushToken() { 54 | u.PushToken = utils.Sha512String(u.Email + utils.RandSalt(saltSize)) 55 | db.Instance.Model(u).Update("push_token", u.PushToken) 56 | } 57 | 58 | func (u *User) SetPassword(plainTextPassword string) { 59 | u.PassSalt = utils.RandSalt(saltSize) 60 | u.Password = utils.Sha512String(plainTextPassword + u.PassSalt) 61 | } 62 | 63 | func UserLogin(email, plainTextPassword string) (u User, success bool) { 64 | result := db.Instance.Preload("Grants").First(&u, "email = ?", email) 65 | if result.Error != nil { 66 | return User{}, false 67 | } 68 | if u.Password != utils.Sha512String(plainTextPassword+u.PassSalt) { 69 | return User{}, false 70 | } 71 | return u, true 72 | } 73 | 74 | func (u *User) GetPermissions() []int { 75 | permissions := []int{} 76 | for _, grant := range u.Grants { 77 | permissions = append(permissions, int(grant.Permission)) 78 | } 79 | return permissions 80 | } 81 | 82 | func (u *User) HasPermission(required Permission) bool { 83 | for _, permission := range u.Grants { 84 | if permission.Permission == required { 85 | return true 86 | } 87 | } 88 | return false 89 | } 90 | 91 | func (u *User) HasPermissions(required []Permission) bool { 92 | for _, permission := range required { 93 | if !u.HasPermission(permission) { 94 | return false 95 | } 96 | } 97 | return true 98 | } 99 | 100 | // GetUsage returns the usage for the current bucket (only) 101 | func (u *User) GetUsage() int64 { 102 | result := int64(-1) 103 | if err := db.Instance.Raw("select ifnull(sum(size+thumb_size), 0) from assets where user_id=? and bucket_id=? and deleted=0", u.ID, u.BucketID).Scan(&result).Error; err != nil { 104 | return -1 105 | } 106 | return result / 1024 / 1024 107 | } 108 | 109 | func (u *User) HasNoRemainingQuota() bool { 110 | return u.Quota < 0 || (u.Quota > 0 && u.GetUsage() >= u.Quota) 111 | } 112 | 113 | func GetUserSocketID(uID uint64) string { 114 | return strconv.Itoa(int(uID)) // TODO: needs db prefix if we move to multi-db setup 115 | } 116 | -------------------------------------------------------------------------------- /models/video_call.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "log" 5 | "server/db" 6 | "server/utils" 7 | ) 8 | 9 | type VideoCall struct { 10 | ID string `gorm:"primaryKey"` 11 | CreatedAt int64 12 | UserID uint64 `gorm:"index:video_call_user;index:uniq_user_group,unique,priority:1"` 13 | User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 14 | GroupID uint64 `gorm:"index:video_call_group;index:uniq_user_group,unique,priority:2"` 15 | ExpiresAt int64 16 | } 17 | 18 | func NewVideoCall(userID uint64, groupID uint64, expiresAt int64) (vc VideoCall, err error) { 19 | vc = VideoCall{ 20 | ID: utils.Rand8BytesToBase62(), 21 | UserID: userID, 22 | GroupID: groupID, 23 | ExpiresAt: expiresAt, 24 | } 25 | err = db.Instance.Create(&vc).Error 26 | return 27 | } 28 | 29 | func VideoCallForUser(userID uint64) (vc VideoCall, err error) { 30 | err = db.Instance. 31 | Preload("User"). 32 | Where("user_id = ? and group_id = 0", userID). 33 | First(&vc). 34 | Error 35 | if vc.ID == "" { 36 | return NewVideoCall(userID, 0, 0) 37 | } 38 | return 39 | } 40 | 41 | func VideoCallForGroup(userID uint64, groupID uint64) (vc VideoCall, err error) { 42 | err = db.Instance. 43 | Preload("User"). 44 | Where("group_id = ?", groupID). 45 | First(&vc). 46 | Error 47 | if vc.ID == "" { 48 | return NewVideoCall(userID, groupID, 0) 49 | } 50 | return 51 | } 52 | 53 | func VideoCallByID(id string) (vc VideoCall, err error) { 54 | err = db.Instance. 55 | Preload("User"). 56 | Where("id = ?", id). 57 | First(&vc). 58 | Error 59 | log.Printf("VideoCallByID, User, Token: %v, %v\n", vc.User.ID, vc.User.PushToken) 60 | return 61 | } 62 | 63 | func (vc *VideoCall) GetOwners() map[uint64]string { 64 | if vc.GroupID > 0 { 65 | return LoadGroupUserIDs(vc.GroupID) 66 | } 67 | return map[uint64]string{vc.User.ID: vc.User.PushToken} 68 | } 69 | -------------------------------------------------------------------------------- /processing/detectfaces.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import ( 4 | "log" 5 | "reflect" 6 | "server/config" 7 | "server/db" 8 | "server/faces" 9 | "server/models" 10 | "server/storage" 11 | "strconv" 12 | ) 13 | 14 | type detectfaces struct{} 15 | 16 | func (t *detectfaces) shouldHandle(asset *models.Asset) bool { 17 | return true 18 | } 19 | 20 | func (t *detectfaces) requiresContent(asset *models.Asset) bool { 21 | return true 22 | } 23 | 24 | func (t *detectfaces) process(asset *models.Asset, storage storage.StorageAPI) (status int, clean func()) { 25 | if !config.FACE_DETECT { 26 | return Skipped, nil 27 | } 28 | 29 | if asset.ThumbPath == "" { 30 | return Failed, nil 31 | } 32 | if storage.GetSize(asset.ThumbPath) <= 0 { 33 | if storage.EnsureLocalFile(asset.ThumbPath) != nil { 34 | return Failed, nil 35 | } 36 | } 37 | clean = func() { 38 | storage.ReleaseLocalFile(asset.ThumbPath) 39 | } 40 | // Extract faces 41 | result, err := faces.Detect(storage.GetFullPath(asset.ThumbPath)) 42 | if err != nil { 43 | log.Printf("Error detecting faces for asset %d, path:%s: %s", asset.ID, asset.ThumbPath, err.Error()) 44 | return Failed, nil 45 | } 46 | // Save faces' data to DB 47 | for i, face := range result { 48 | faceModel := models.Face{ 49 | UserID: asset.UserID, 50 | AssetID: asset.ID, 51 | Num: i, 52 | X1: face.Rectangle.Min.X, 53 | Y1: face.Rectangle.Min.Y, 54 | X2: face.Rectangle.Max.X, 55 | Y2: face.Rectangle.Max.Y, 56 | PersonID: nil, 57 | } 58 | // Use reflection to set Vx fields to the corresponding value from the array 59 | for j, value := range face.Descriptor { 60 | reflect.ValueOf(&faceModel).Elem().FieldByName("V" + strconv.Itoa(j)).SetFloat(float64(value)) 61 | } 62 | if err := db.Instance.Create(&faceModel).Error; err != nil { 63 | log.Printf("Error saving face location for asset %d: %v", asset.ID, err) 64 | return Failed, nil 65 | } 66 | // Find the face that is most similar (least distance) to this one and fetch it's person_id 67 | db.Instance.Raw(`select t2.person_id, `+models.FacesVectorDistance+` as threshold 68 | from faces t1 join faces t2 69 | where t1.id=? and t1.user_id=t2.user_id and t1.user_id=? and t2.person_id is not null and t1.id != t2.id 70 | order by threshold limit 1`, faceModel.ID, asset.UserID).Row().Scan(&faceModel.PersonID, &faceModel.Distance) 71 | log.Printf("Face %d, threshold: %f\n", faceModel.ID, faceModel.Distance) 72 | if faceModel.PersonID != nil && faceModel.Distance <= config.FACE_MAX_DISTANCE_SQ { 73 | // Update the current face with the found person_id 74 | db.Instance.Exec("update faces set person_id=? where id=?", *faceModel.PersonID, faceModel.ID) 75 | log.Printf("Updated face %d, person_id: %d\n", faceModel.ID, *faceModel.PersonID) 76 | } 77 | } 78 | return Done, clean 79 | } 80 | -------------------------------------------------------------------------------- /processing/location.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import ( 4 | "log" 5 | "server/db" 6 | "server/locations" 7 | "server/models" 8 | "server/storage" 9 | ) 10 | 11 | type location struct{} 12 | 13 | func (l *location) shouldHandle(asset *models.Asset) bool { 14 | return asset.GpsLat != nil && asset.GpsLong != nil && asset.PlaceID == nil 15 | } 16 | 17 | func (l *location) requiresContent(asset *models.Asset) bool { 18 | return false 19 | } 20 | 21 | func (l *location) process(asset *models.Asset, storage storage.StorageAPI) (int, func()) { 22 | // Try first local DB 23 | location := asset.GetRoughLocation() 24 | var result []models.Location 25 | db.Instance.Where("gps_lat=? and gps_long=?", location.GpsLat, location.GpsLong).Find(&result) 26 | if len(result) > 0 { 27 | placeID := result[0].GetPlaceID() 28 | if placeID > 0 { 29 | asset.PlaceID = &placeID 30 | if db.Instance.Save(asset).Error != nil { 31 | return FailedDB, nil 32 | } 33 | return Done, nil 34 | } 35 | } 36 | // Try a Nominatim request 37 | nominatim := locations.GetNominatimLocation(location.GpsLat, location.GpsLong) 38 | if nominatim == nil { 39 | log.Printf("No location found for: %d, %f, %f", asset.ID, location.GpsLat, location.GpsLong) 40 | return Failed, nil 41 | } 42 | // Create local DB record 43 | location.Display = nominatim.DisplayName 44 | location.Area = nominatim.GetArea() 45 | location.City = nominatim.GetCity() 46 | location.Country = nominatim.Address.Country 47 | location.CountryCode = nominatim.Address.CountryCode 48 | res := db.Instance.Create(&location) 49 | if res.Error != nil { 50 | log.Printf("DB error: %+v", res.Error) 51 | return FailedDB, nil 52 | } 53 | // Do we have a corresponding place already in our DB? 54 | placeID := location.GetPlaceID() 55 | if placeID == 0 { 56 | return Failed, nil 57 | } 58 | asset.PlaceID = &placeID 59 | if db.Instance.Save(asset).Error != nil { 60 | return FailedDB, nil 61 | } 62 | return Done, nil 63 | } 64 | -------------------------------------------------------------------------------- /processing/metadata.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "os/exec" 7 | "server/db" 8 | "server/models" 9 | "server/storage" 10 | "server/utils" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/zsefvlol/timezonemapper" 16 | ) 17 | 18 | type metadata struct{} 19 | 20 | func (md *metadata) shouldHandle(asset *models.Asset) bool { 21 | return asset.Width == 0 || 22 | asset.Height == 0 || 23 | (asset.Duration == 0 && asset.IsVideo()) || 24 | asset.UpdatedAt-asset.CreatedAt < 60 || 25 | asset.TimeOffset == nil 26 | } 27 | 28 | func (md *metadata) requiresContent(asset *models.Asset) bool { 29 | return true 30 | } 31 | 32 | func (md *metadata) process(asset *models.Asset, storage storage.StorageAPI) (int, func()) { 33 | cmd := exec.Command("exiftool", "-n", "-T", "-gpslatitude", "-gpslongitude", "-imagewidth", "-imageheight", "-duration", "-createdate", "-offsettime", storage.GetFullPath(asset.Path)) 34 | output, err := cmd.Output() 35 | if err != nil { 36 | log.Printf("Metadata processing error: %v; output: %s", err, output) 37 | return Failed, nil 38 | } 39 | result := strings.Split(strings.Trim(string(output), "\n\t\r "), "\t") 40 | if len(result) == 7 { 41 | if result[0] != "-" { 42 | asset.GpsLat = utils.StringToFloat64Ptr(result[0]) 43 | } 44 | if result[1] != "-" { 45 | asset.GpsLong = utils.StringToFloat64Ptr(result[1]) 46 | } 47 | if result[2] != "-" { 48 | asset.Width = utils.StringToUInt16(result[2]) 49 | } 50 | if result[3] != "-" { 51 | asset.Height = utils.StringToUInt16(result[3]) 52 | } 53 | if result[4] != "-" { 54 | d := utils.StringToFloat64Ptr(result[4]) 55 | asset.Duration = uint32(math.Ceil(*d)) 56 | } 57 | if result[6] != "-" { 58 | asset.TimeOffset = getTimeOffsetFrom(result[6]) 59 | } 60 | // Still not having the time offset, but we have the GPS coordinates? 61 | if asset.TimeOffset == nil && asset.GpsLat != nil && asset.GpsLong != nil { 62 | zone, err := time.LoadLocation(timezonemapper.LatLngToTimezoneString(*asset.GpsLat, *asset.GpsLong)) 63 | if err == nil && zone != nil { 64 | _, offset := time.Now().In(zone).Zone() 65 | asset.TimeOffset = &offset 66 | } 67 | } 68 | if result[5] != "-" { 69 | if t, err := time.Parse("2006:01:02 15:04:05", result[5]); err == nil { 70 | asset.CreatedAt = t.Unix() 71 | if asset.TimeOffset != nil { 72 | asset.CreatedAt -= int64(*asset.TimeOffset) 73 | } 74 | } 75 | } 76 | } 77 | if err = db.Instance.Save(&asset).Error; err != nil { 78 | log.Printf("Error updating DB for asset ID %d: %v", asset.ID, err) 79 | return FailedDB, nil 80 | } 81 | return Done, nil 82 | } 83 | 84 | // getTimeOffsetFrom return offset in seconds (or nil on error), input format is "+09:00" 85 | func getTimeOffsetFrom(s string) *int { 86 | parts := strings.SplitN(s, ":", 2) 87 | if len(parts) != 2 { 88 | return nil 89 | } 90 | hours, err := strconv.Atoi(parts[0]) 91 | if err != nil { 92 | return nil 93 | } 94 | mins, err := strconv.Atoi(parts[1]) 95 | if err != nil { 96 | return nil 97 | } 98 | result := hours * 3600 99 | if hours < 0 { 100 | result -= mins * 60 101 | } else { 102 | result += mins * 60 103 | } 104 | return &result 105 | } 106 | -------------------------------------------------------------------------------- /processing/metadata_test.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import "testing" 4 | 5 | func Test_getTimeOffsetFrom(t *testing.T) { 6 | t9 := 9 * 3600 7 | t95 := 9*3600 + 1800 8 | m95 := -(9*3600 + 1800) 9 | t0 := 0 10 | type args struct { 11 | s string 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want *int 17 | }{ 18 | { 19 | "err", 20 | args{"asas"}, 21 | nil, 22 | }, 23 | { 24 | "+09:00", 25 | args{"+09:00"}, 26 | &t9, 27 | }, 28 | { 29 | "+00:00", 30 | args{"+00:00"}, 31 | &t0, 32 | }, 33 | { 34 | "+09:30", 35 | args{"+09:30"}, 36 | &t95, 37 | }, 38 | { 39 | "-09:30", 40 | args{"-09:30"}, 41 | &m95, 42 | }, 43 | } 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | got := getTimeOffsetFrom(tt.args.s) 47 | if got == tt.want || (got != nil && tt.want != nil && *got == *tt.want) { 48 | return // ok 49 | } 50 | t.Errorf("getTimeOffsetFrom() = %v, want %v", got, tt.want) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /processing/processing.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "reflect" 7 | "server/db" 8 | "server/models" 9 | "server/storage" 10 | "time" 11 | ) 12 | 13 | type processingTask interface { 14 | shouldHandle(*models.Asset) bool 15 | requiresContent(*models.Asset) bool // This method is necessary to establish if we need to download remote file contents 16 | process(*models.Asset, storage.StorageAPI) (status int, cleanup func()) 17 | } 18 | 19 | type processingTasksElement struct { 20 | name string 21 | task processingTask 22 | } 23 | 24 | type pendingTask struct { 25 | assetID uint64 26 | status string 27 | recordID *uint64 28 | } 29 | 30 | type processingTasks []processingTasksElement 31 | 32 | var tasks = processingTasks{} 33 | 34 | func Init() { 35 | if err := db.Instance.AutoMigrate(&ProcessingTask{}); err != nil { 36 | log.Printf("Auto-migrate error: %v", err) 37 | } 38 | // Register all processing tasks (executed in the same order) 39 | tasks.register(&location{}) 40 | tasks.register(&videoConvert{}) 41 | tasks.register(&metadata{}) 42 | tasks.register(&thumb{}) 43 | tasks.register(&detectfaces{}) 44 | } 45 | 46 | func (ts *processingTasks) register(t processingTask) { 47 | *ts = append(*ts, processingTasksElement{ 48 | name: reflect.TypeOf(t).Elem().Name(), 49 | task: t, 50 | }) 51 | } 52 | 53 | func (ts *processingTasks) requireContent(asset *models.Asset) bool { 54 | for _, e := range *ts { 55 | if e.task.requiresContent(asset) && e.task.shouldHandle(asset) { 56 | return true 57 | } 58 | } 59 | return false 60 | } 61 | 62 | func (ts *processingTasks) process(asset *models.Asset, assetStorage storage.StorageAPI, statusMap map[string]int) { 63 | // Cleanup tasks for the current asset 64 | cleanAll := []func(){} 65 | for _, e := range *ts { 66 | if _, ok := statusMap[e.name]; ok { 67 | // For now - just one try for each task 68 | continue 69 | } 70 | if !e.task.shouldHandle(asset) { 71 | statusMap[e.name] = Skipped 72 | continue 73 | } 74 | if e.task.requiresContent(asset) && assetStorage == nil { 75 | statusMap[e.name] = FailedStorage 76 | continue 77 | } 78 | // Use a copy to avoid modifications in case of failure 79 | assetCopy := *asset 80 | start := time.Now() 81 | status, cleanup := e.task.process(&assetCopy, assetStorage) 82 | timeConsumed := time.Since(start).Milliseconds() 83 | // In case of success copy modifications to original so next task can use that 84 | if status == Done { 85 | *asset = assetCopy 86 | } 87 | statusMap[e.name] = status 88 | if cleanup != nil { 89 | cleanAll = append(cleanAll, cleanup) 90 | } 91 | log.Printf("Task \"%s\", asset ID: %d, result: %s, time: %v", e.name, asset.ID, statusConstMap[statusMap[e.name]], timeConsumed) 92 | } 93 | for _, clean := range cleanAll { 94 | clean() 95 | } 96 | } 97 | 98 | // TODO: 2 or more in parallel? Depending on CPU count? 99 | func processPending() { 100 | // All assets that don't hvave processing_tasks record, OR 101 | // status has fewer tasks performed than the currently available ones 102 | rows, err := db.Instance. 103 | Table("assets"). 104 | Joins("left join processing_tasks ON (assets.id = processing_tasks.asset_id)"). 105 | Select("assets.id, IFNULL(processing_tasks.status, ''), processing_tasks.asset_id"). 106 | Where("assets.deleted=0 AND "+ 107 | "assets.size>0 AND "+ 108 | db.TimestampFunc+"-assets.updated_at>30 AND "+ 109 | "(processing_tasks.status IS NULL OR "+ 110 | " LENGTH(processing_tasks.status)-LENGTH(REPLACE(processing_tasks.status, ',', ''))+1 < ?)", len(tasks)). 111 | Order("assets.created_at").Rows() 112 | if err != nil { 113 | log.Printf("processPending error: %v", err) 114 | return 115 | } 116 | // Create an array of temp structs to hold the fetched data 117 | pendingTasks := []pendingTask{} 118 | for rows.Next() { 119 | assetID := uint64(0) 120 | status := "" 121 | var recordId *uint64 122 | if err = rows.Scan(&assetID, &status, &recordId); err != nil { 123 | log.Printf("processPending row error: %v", err) 124 | break 125 | } 126 | pendingTasks = append(pendingTasks, pendingTask{ 127 | assetID: assetID, 128 | status: status, 129 | recordID: recordId, 130 | }) 131 | } 132 | rows.Close() 133 | // Above was needed as sqlite3 was locking 134 | for _, task := range pendingTasks { 135 | asset := models.Asset{ 136 | ID: task.assetID, 137 | } 138 | if err = db.Instance.Preload("Bucket").Preload("User").First(&asset).Error; err != nil { 139 | log.Printf("processPending load asset error: %v, asset: %d", err, asset.ID) 140 | break 141 | } 142 | current := ProcessingTask{ 143 | AssetID: asset.ID, 144 | Status: task.status, 145 | } 146 | var assetStorage storage.StorageAPI 147 | if tasks.requireContent(&asset) { 148 | // Ensure we actually have access to the asset contents 149 | assetStorage = storage.StorageFrom(&asset.Bucket) 150 | if assetStorage == nil { 151 | fmt.Printf("processPending: Storage is nil for asset ID: %d", asset.ID) 152 | } else { 153 | if err = assetStorage.EnsureLocalFile(asset.Path); err != nil { 154 | fmt.Printf("Error downloading remote file for %s: %v\n", asset.Path, err) 155 | assetStorage = nil 156 | } else { 157 | // In the end - cleanup local copy 158 | defer assetStorage.ReleaseLocalFile(asset.Path) 159 | } 160 | } 161 | } 162 | statusMap := current.statusToMap() 163 | tasks.process(&asset, assetStorage, statusMap) 164 | current.updateWith(statusMap) 165 | if task.recordID == nil { 166 | // This is a new record 167 | err = db.Instance.Create(¤t).Error 168 | } else { 169 | // This is an update 170 | err = db.Instance.Save(¤t).Error 171 | } 172 | if err != nil { 173 | log.Printf("processPending save task error: %v", err) 174 | } 175 | } 176 | } 177 | 178 | func StartProcessing() { 179 | for { 180 | processPending() 181 | time.Sleep(10 * time.Second) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /processing/processing_task.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import ( 4 | "log" 5 | "server/models" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | Skipped = 0 12 | UserSkipped = 1 13 | Done = 2 14 | Failed = 3 15 | FailedStorage = 4 16 | FailedDB = 5 17 | ) 18 | 19 | var ( 20 | // Map containing the status codes from above and their string representation 21 | statusConstMap = map[int]string{ 22 | Skipped: "Skipped", 23 | UserSkipped: "UserSkipped", 24 | Done: "Done", 25 | Failed: "Failed", 26 | FailedStorage: "FailedStorage", 27 | FailedDB: "FailedDB", 28 | } 29 | ) 30 | 31 | type ProcessingTask struct { 32 | AssetID uint64 `gorm:"primaryKey"` 33 | Asset models.Asset `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 34 | Status string `gorm:"type:varchar(1024)"` // Contains comma-separated pairs of integration and status, e.g. "video:1,thumb:2,another:0" 35 | } 36 | 37 | func (pt *ProcessingTask) statusToMap() map[string]int { 38 | result := map[string]int{} 39 | if pt.Status == "" { 40 | return result 41 | } 42 | for _, v := range strings.Split(pt.Status, ",") { 43 | current := strings.Split(v, ":") 44 | if len(current) != 2 { 45 | log.Printf("Task status contains invalid chars, asset: %d, status: %s", pt.AssetID, pt.Status) 46 | continue 47 | } 48 | result[current[0]], _ = strconv.Atoi(current[1]) 49 | } 50 | return result 51 | } 52 | 53 | func (pt *ProcessingTask) updateWith(statusMap map[string]int) { 54 | result := []string{} 55 | for k, v := range statusMap { 56 | result = append(result, k+":"+strconv.Itoa(v)) 57 | } 58 | pt.Status = strings.Join(result, ",") 59 | } 60 | -------------------------------------------------------------------------------- /processing/thumb.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "log" 7 | "os/exec" 8 | "server/db" 9 | "server/models" 10 | "server/storage" 11 | ) 12 | 13 | type thumb struct{} 14 | 15 | func (t *thumb) shouldHandle(asset *models.Asset) bool { 16 | return asset.ThumbSize == 0 17 | } 18 | 19 | func (t *thumb) requiresContent(asset *models.Asset) bool { 20 | return true 21 | } 22 | 23 | func (t *thumb) process(asset *models.Asset, storage storage.StorageAPI) (status int, clean func()) { 24 | thumbPath := asset.CreateThumbPath() 25 | cmd := exec.Command("ffmpeg", "-y", "-i", storage.GetFullPath(asset.Path), "-vf", "scale=min(1280\\,iw):-1", "-ss", "00:00:00.000", "-vframes", "1", storage.GetFullPath(thumbPath)) 26 | err := cmd.Run() 27 | if err != nil { 28 | log.Printf("Error creating thumbnail for asset %d, path:%s: %s", asset.ID, thumbPath, err.Error()) 29 | return Failed, nil 30 | } 31 | buf := bytes.Buffer{} 32 | if _, err = storage.Load(thumbPath, &buf); err != nil { 33 | log.Printf("Cannot load newly created thumbnail for asset ID %d (%s) : %v", asset.ID, thumbPath, err) 34 | return Failed, nil 35 | } 36 | // Remove the temporary local file (in case of remote storage) 37 | clean = func() { 38 | storage.ReleaseLocalFile(thumbPath) 39 | } 40 | 41 | asset.ThumbSize = int64(buf.Len()) 42 | thumb, _, err := image.Decode(&buf) 43 | if err != nil { 44 | log.Printf("Error decoding thumbnail for ID %d (%s): %v", asset.ID, thumbPath, err) 45 | return Failed, clean 46 | } 47 | asset.ThumbPath = thumbPath 48 | asset.ThumbWidth = uint16(thumb.Bounds().Dx()) 49 | asset.ThumbHeight = uint16(thumb.Bounds().Dy()) 50 | asset.PresignedThumbUntil = 0 // Clear S3 URL cache 51 | if err = db.Instance.Save(&asset).Error; err != nil { 52 | log.Printf("Error saving asset to DB for ID %d: %v", asset.ID, err) 53 | return Failed, clean 54 | } 55 | if err = storage.UpdateRemoteFile(asset.ThumbPath, "image/jpeg"); err != nil { 56 | asset.ThumbSize = 0 // Revert 57 | asset.ThumbPath = "" 58 | db.Instance.Save(&asset) 59 | log.Printf("Error in storage.UpdateFile for asset ID %d (%s): %v", asset.ID, thumbPath, err) 60 | return Failed, nil 61 | } 62 | return Done, clean 63 | } 64 | -------------------------------------------------------------------------------- /processing/video.go: -------------------------------------------------------------------------------- 1 | package processing 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os/exec" 7 | "path/filepath" 8 | "server/db" 9 | "server/models" 10 | "server/storage" 11 | ) 12 | 13 | type videoConvert struct{} 14 | 15 | func (vc *videoConvert) shouldHandle(asset *models.Asset) bool { 16 | return asset.IsVideo() && asset.MimeType != "video/mp4" 17 | } 18 | 19 | func (vc *videoConvert) requiresContent(asset *models.Asset) bool { 20 | return true 21 | } 22 | 23 | func (vc *videoConvert) process(asset *models.Asset, storage storage.StorageAPI) (status int, clean func()) { 24 | if asset.User.VideoSetting == models.VideoSettingSkip { 25 | return UserSkipped, nil 26 | } 27 | oldPath := asset.Path 28 | ext := filepath.Ext(asset.Name) 29 | asset.Name = asset.Name[:len(asset.Name)-len(ext)] + ".mp4" 30 | asset.Path = asset.Path[:len(asset.Path)-len(ext)] + ".mp4" 31 | err := ffmpegConvert(storage.GetFullPath(oldPath), storage.GetFullPath(asset.Path)) 32 | asset.Size = storage.GetSize(asset.Path) 33 | // Always cleanup in the end 34 | clean = func() { 35 | // Delete the temp file after all tasks have completed 36 | storage.ReleaseLocalFile(asset.Path) 37 | } 38 | if err != nil || asset.Size <= 0 { 39 | fmt.Printf("ERROR in video processing for: %s, %v, size: %v\n", oldPath, err, asset.Size) 40 | return Failed, clean 41 | } 42 | log.Print("DONE video processing for:", asset.Path) 43 | 44 | asset.MimeType = "video/mp4" 45 | asset.PresignedUntil = 0 46 | if err := storage.UpdateRemoteFile(asset.Path, asset.MimeType); err != nil { 47 | log.Printf("Error updating asset ID %d (%s->%s): %v", asset.ID, oldPath, asset.Path, err) 48 | return Failed, clean 49 | } 50 | if err = db.Instance.Save(&asset).Error; err != nil { 51 | log.Printf("Error updating DB for asset ID %d: %v", asset.ID, err) 52 | return Failed, clean 53 | } 54 | // Delete old files and objects 55 | err1 := storage.DeleteRemoteFile(oldPath) 56 | err2 := storage.Delete(oldPath) 57 | if err1 != nil || err2 != nil { 58 | log.Printf("Error deleting old objects for asset ID %d (%s), errors (remote,local): %v, %v", asset.ID, oldPath, err1, err2) 59 | } 60 | return Done, clean 61 | } 62 | 63 | // ffmpegConvert uses hard-coded options 64 | func ffmpegConvert(inFile, outFile string) error { 65 | log.Printf("Converting file %s to %s", inFile, outFile) 66 | cmd := exec.Command("ffmpeg", "-y", "-i", inFile, "-c:v", "libx264", "-c:a", "aac", "-b:a", "128k", "-crf", "24", "-movflags", "use_metadata_tags", "-map_metadata", "0", outFile) 67 | return cmd.Run() 68 | } 69 | -------------------------------------------------------------------------------- /push/album.go: -------------------------------------------------------------------------------- 1 | package push 2 | 3 | import ( 4 | "log" 5 | "server/config" 6 | "server/db" 7 | "server/models" 8 | "strconv" 9 | ) 10 | 11 | func AlbumNewContributor(newUser, albumId uint64, mode uint8, addeByUser *models.User) { 12 | if config.PUSH_SERVER == "" { 13 | return 14 | } 15 | receiver := models.User{ID: newUser} 16 | if err := db.Instance.First(&receiver).Error; err != nil { 17 | log.Printf("AlbumNewContributor DB error: %v", err) 18 | return 19 | } 20 | album := models.Album{ID: albumId} 21 | if db.Instance.First(&album).Error != nil { 22 | log.Print("Cannot find album?") 23 | return 24 | } 25 | what := "viewer. You can now see the photos in the album" 26 | if mode == models.ContributorCanAdd { 27 | what = "contributor. You can now see and add more photos" 28 | } 29 | notification := Notification{ 30 | UserTokens: []string{receiver.PushToken}, 31 | Title: "Album \"" + album.Name + "\"", 32 | Body: addeByUser.Name + " added you as a " + what, 33 | Data: map[string]string{ 34 | "type": NotificationTypeNewAssetsInAlbum, 35 | "album": strconv.Itoa(int(albumId)), 36 | }, 37 | } 38 | notification.Send() 39 | } 40 | 41 | func AlbumNewAssets(count int, albumId uint64, addeByUser *models.User) { 42 | if config.PUSH_SERVER == "" { 43 | return 44 | } 45 | // TODO: Use raw queries instead? 46 | album := models.Album{ID: albumId} 47 | if db.Instance. 48 | Preload("User"). 49 | Preload("Contributors"). 50 | First(&album).Error != nil { 51 | 52 | log.Print("Cannot find album?") 53 | return 54 | } 55 | what := strconv.Itoa(count) + " new photo" 56 | if count > 1 { 57 | what += "s" 58 | } 59 | notification := Notification{ 60 | Title: "Album \"" + album.Name + "\"", 61 | Body: addeByUser.Name + " added " + what, 62 | Data: map[string]string{ 63 | "type": NotificationTypeNewAssetsInAlbum, 64 | "album": strconv.Itoa(int(albumId)), 65 | }, 66 | } 67 | if album.UserID != addeByUser.ID { 68 | notification.UserTokens = []string{album.User.PushToken} 69 | notification.Send() 70 | } 71 | for _, c := range album.Contributors { 72 | if addeByUser.ID == c.UserID { 73 | continue 74 | } 75 | if c.User.ID != c.UserID { 76 | c.User.ID = c.UserID 77 | db.Instance.First(&c.User) 78 | } 79 | if c.User.PushToken == "" { 80 | continue 81 | } 82 | notification.UserTokens = []string{c.User.PushToken} 83 | notification.Send() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /push/push.go: -------------------------------------------------------------------------------- 1 | package push 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "server/config" 11 | ) 12 | 13 | const ( 14 | NotificationTypeNewAssetsInAlbum = "album" 15 | NotificationTypeCall = "call" 16 | ) 17 | 18 | var httpClient = http.Client{} 19 | 20 | type Notification struct { 21 | Type string `json:"type"` 22 | UserTokens []string `json:"user_tokens" binding:"required"` 23 | Title string `json:"title"` 24 | Body string `json:"body"` 25 | Data map[string]string `json:"data"` 26 | } 27 | 28 | func (notification *Notification) SendTo(UserTokens []string) error { 29 | notification.UserTokens = UserTokens 30 | return notification.Send() 31 | } 32 | 33 | func (notification *Notification) Send() error { 34 | buf := bytes.Buffer{} 35 | json.NewEncoder(&buf).Encode(*notification) 36 | resp, err := httpClient.Post(config.PUSH_SERVER+"/send", "application/json", &buf) 37 | if err != nil { 38 | log.Printf("SendPushNotification, error: %v", err) 39 | return err 40 | } 41 | defer resp.Body.Close() 42 | if resp.StatusCode != 200 { 43 | buf.Reset() 44 | io.Copy(&buf, resp.Body) 45 | log.Printf("SendPushNotification error, status: %d, %s", resp.StatusCode, buf.String()) 46 | return fmt.Errorf("status: %d", resp.StatusCode) 47 | } 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /static/cam-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circled-me/server/f84d8138c6eadb4e77ca37fdfc6600d7870ad60c/static/cam-off.png -------------------------------------------------------------------------------- /static/cam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circled-me/server/f84d8138c6eadb4e77ca37fdfc6600d7870ad60c/static/cam.png -------------------------------------------------------------------------------- /static/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circled-me/server/f84d8138c6eadb4e77ca37fdfc6600d7870ad60c/static/close.png -------------------------------------------------------------------------------- /static/mic2-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circled-me/server/f84d8138c6eadb4e77ca37fdfc6600d7870ad60c/static/mic2-off.png -------------------------------------------------------------------------------- /static/mic2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/circled-me/server/f84d8138c6eadb4e77ca37fdfc6600d7870ad60c/static/mic2.png -------------------------------------------------------------------------------- /storage/bucket.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net/url" 7 | "server/db" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/aws/credentials" 12 | "github.com/aws/aws-sdk-go/aws/session" 13 | "github.com/aws/aws-sdk-go/service/s3" 14 | "golang.org/x/sys/unix" 15 | ) 16 | 17 | type StorageType uint8 18 | 19 | const ( 20 | StorageTypeFile StorageType = 0 21 | StorageTypeS3 StorageType = 1 22 | ) 23 | const ( 24 | StorageLocationUser = "/user" 25 | StorageLocationGroup = "/group" 26 | ) 27 | 28 | type Bucket struct { 29 | ID uint64 `gorm:"primaryKey" json:"id"` 30 | CreatedAt int `json:"-"` 31 | UpdatedAt int `json:"-"` 32 | Name string `gorm:"type:varchar(200)" json:"name"` 33 | AssetPathPattern string `gorm:"type:varchar(200)" json:"asset_path_pattern"` 34 | StorageType StorageType `json:"storage_type"` 35 | Path string `gorm:"type:varchar(300)" json:"path"` // Path on a drive or a prefix (for S3 buckets) 36 | Endpoint string `gorm:"type:varchar(300)" json:"endpoint"` // URL for S3 buckets; if empty - defaults to AWS S3 37 | S3Key string `gorm:"type:varchar(200)" json:"s3key"` 38 | S3Secret string `gorm:"type:varchar(200)" json:"s3secret"` 39 | Region string `gorm:"type:varchar(20)" json:"s3region"` // Defaults to us-east-1 40 | SSEEncryption string `gorm:"type:varchar(20)" json:"s3encryption"` // Server-side encryption (or empty for no encryption) 41 | } 42 | 43 | func (b *Bucket) IsS3() bool { 44 | return b.StorageType == StorageTypeS3 45 | } 46 | 47 | func (b *Bucket) CanSave() (err error) { 48 | if b.ID > 0 { 49 | count := int64(0) 50 | if db.Instance.Raw("select exists(select id from assets where deleted=0 and bucket_id=?)", b.ID).Scan(&count).Error != nil { 51 | return errors.New("DB error") 52 | } 53 | if count != 0 { 54 | return errors.New("Cannot modify bucket as it is already in use") 55 | } 56 | } 57 | if b.StorageType == StorageTypeS3 { 58 | _, err = url.Parse(b.Path) 59 | } 60 | return 61 | } 62 | 63 | func (b *Bucket) GetRemotePath(path string) string { 64 | return b.Path + "/" + path 65 | } 66 | 67 | // TODO: Do not create session, etc twice (for main and thumb separately) 68 | func (b *Bucket) CreateS3UploadURI(path string) string { 69 | svc := b.CreateSVC() 70 | input := &s3.PutObjectInput{ 71 | Bucket: &b.Name, 72 | Key: aws.String(b.GetRemotePath(path)), 73 | } 74 | if b.SSEEncryption != "" { 75 | input.ServerSideEncryption = &b.SSEEncryption 76 | } 77 | req, _ := svc.PutObjectRequest(input) 78 | out, err := req.Presign(15 * time.Minute) 79 | if err != nil { 80 | log.Printf("Cannot sign request: %v", err) 81 | return err.Error() 82 | } 83 | return out 84 | } 85 | 86 | func (b *Bucket) CreateS3DownloadURI(path string, expiry time.Duration) string { 87 | svc := b.CreateSVC() 88 | req, _ := svc.GetObjectRequest(&s3.GetObjectInput{ 89 | Bucket: &b.Name, 90 | Key: aws.String(b.GetRemotePath(path)), 91 | }) 92 | out, err := req.Presign(expiry) 93 | if err != nil { 94 | log.Printf("Cannot sign request 2: %v", err) 95 | return err.Error() 96 | } 97 | return out 98 | } 99 | 100 | func (b *Bucket) CreateSVC() *s3.S3 { 101 | config := &aws.Config{ 102 | Region: &b.Region, 103 | Credentials: credentials.NewStaticCredentials(b.S3Key, b.S3Secret, ""), 104 | } 105 | if b.Endpoint != "" { 106 | config.Endpoint = &b.Endpoint 107 | } 108 | sess, _ := session.NewSession(config) 109 | return s3.New(sess) 110 | } 111 | 112 | func (b *Bucket) GetUsage() int64 { 113 | result := int64(-1) 114 | if err := db.Instance.Raw("select ifnull(sum(size+thumb_size), 0) from assets where bucket_id=? and deleted=0", b.ID).Scan(&result).Error; err != nil { 115 | return -1 116 | } 117 | return result 118 | } 119 | 120 | func (b *Bucket) GetSpaceInfo() (available, size int64) { 121 | if b.StorageType != StorageTypeFile { 122 | return -1, -1 // unknown for S3 or any other storage type 123 | } 124 | var stat unix.Statfs_t 125 | unix.Statfs(b.Path, &stat) 126 | return int64(stat.Bavail) * int64(stat.Bsize), int64(stat.Blocks) * int64(stat.Bsize) 127 | } 128 | -------------------------------------------------------------------------------- /storage/disk.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | ) 7 | 8 | type DiskStorage struct { 9 | Storage 10 | // BasePath is a directory (usually mount point of a disk) that is writable by the current process 11 | BasePath string 12 | dirs map[string]bool // local cache of created dirs 13 | dirsMutex sync.Mutex 14 | } 15 | 16 | func (s *DiskStorage) EnsureDirExists(dir string) error { 17 | s.dirsMutex.Lock() 18 | defer s.dirsMutex.Unlock() 19 | 20 | if ok := s.dirs[dir]; ok { 21 | return nil 22 | } 23 | s.dirs[dir] = true 24 | return os.MkdirAll(dir, 0777) 25 | } 26 | 27 | func (s *DiskStorage) GetFullPath(path string) string { 28 | return s.BasePath + "/" + path 29 | } 30 | 31 | func NewDiskStorage(bucket *Bucket) StorageAPI { 32 | result := &DiskStorage{ 33 | BasePath: bucket.Path, 34 | Storage: Storage{ 35 | Bucket: *bucket, 36 | }, 37 | dirs: make(map[string]bool, 10), 38 | } 39 | result.specifics = result 40 | return result 41 | } 42 | 43 | func (s *DiskStorage) EnsureLocalFile(path string) error { 44 | return nil // noop 45 | } 46 | 47 | func (s *DiskStorage) ReleaseLocalFile(path string) { 48 | // noop 49 | } 50 | 51 | func (s *DiskStorage) UpdateRemoteFile(path, mimeType string) error { 52 | return nil // noop 53 | } 54 | 55 | func (s *DiskStorage) DeleteRemoteFile(path string) error { 56 | return nil // noop 57 | } 58 | -------------------------------------------------------------------------------- /storage/s3.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "server/config" 7 | "strings" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/s3" 11 | "github.com/aws/aws-sdk-go/service/s3/s3manager" 12 | ) 13 | 14 | type S3Storage struct { 15 | Storage 16 | s3Client *s3.S3 17 | } 18 | 19 | // GetFullPath returns local temp path in case of S3 20 | func (s *S3Storage) GetFullPath(path string) string { 21 | return config.TMP_DIR + "/" + strings.ReplaceAll(path, "/", "_") 22 | } 23 | 24 | func (s *S3Storage) EnsureDirExists(dir string) error { 25 | return nil 26 | } 27 | 28 | func NewS3Storage(bucket *Bucket) StorageAPI { 29 | result := &S3Storage{ 30 | Storage: Storage{ 31 | Bucket: *bucket, 32 | }, 33 | s3Client: bucket.CreateSVC(), 34 | } 35 | result.specifics = result 36 | return result 37 | } 38 | 39 | // EnsureLocalFile downloads a S3 object locally 40 | func (s *S3Storage) EnsureLocalFile(path string) error { 41 | // S3 request 42 | resp, err := s.s3Client.GetObject(&s3.GetObjectInput{ 43 | Bucket: &s.Bucket.Name, 44 | Key: aws.String(s.Bucket.GetRemotePath(path)), 45 | }) 46 | if err != nil { 47 | return err 48 | } 49 | // Lcoal file 50 | out, err := os.Create(s.GetFullPath(path)) 51 | if err != nil { 52 | return err 53 | } 54 | defer out.Close() 55 | 56 | _, err = io.Copy(out, resp.Body) 57 | return err 58 | } 59 | 60 | func (s *S3Storage) ReleaseLocalFile(path string) { 61 | _ = s.Delete(path) 62 | } 63 | 64 | // UpdateFile updates the remote S3 object (uploads the local copy) 65 | func (s *S3Storage) UpdateRemoteFile(path, mimeType string) error { 66 | data, err := os.Open(s.GetFullPath(path)) 67 | if err != nil { 68 | return err 69 | } 70 | defer data.Close() 71 | 72 | uploader := s3manager.NewUploaderWithClient(s.s3Client) 73 | input := s3manager.UploadInput{ 74 | Bucket: &s.Bucket.Name, 75 | Key: aws.String(s.Bucket.GetRemotePath(path)), 76 | ContentType: &mimeType, 77 | Body: data, 78 | } 79 | if s.Bucket.SSEEncryption != "" { 80 | input.ServerSideEncryption = &s.Bucket.SSEEncryption 81 | } 82 | _, err = uploader.Upload(&input) 83 | 84 | return err 85 | } 86 | 87 | func (s *S3Storage) DeleteRemoteFile(path string) error { 88 | _, err := s.s3Client.DeleteObject(&s3.DeleteObjectInput{ 89 | Bucket: &s.Bucket.Name, 90 | Key: aws.String(s.Bucket.GetRemotePath(path)), 91 | }) 92 | return err 93 | } 94 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "server/config" 11 | "server/db" 12 | ) 13 | 14 | type StorageSpecificAPI interface { 15 | // GetFullPath always returns local path (tmp path for remote storage) 16 | GetFullPath(path string) string 17 | EnsureDirExists(dir string) error 18 | EnsureLocalFile(path string) error 19 | ReleaseLocalFile(path string) 20 | DeleteRemoteFile(path string) error 21 | UpdateRemoteFile(path, mimeType string) error 22 | } 23 | 24 | type StorageAPI interface { 25 | StorageSpecificAPI 26 | 27 | GetSize(path string) int64 28 | Save(path string, reader io.Reader) (int64, error) 29 | Load(path string, writer io.Writer) (int64, error) 30 | Serve(path string, request *http.Request, writer http.ResponseWriter) 31 | Delete(path string) error 32 | GetBucket() *Bucket 33 | } 34 | 35 | type Storage struct { 36 | StorageAPI 37 | specifics StorageAPI 38 | Bucket Bucket 39 | } 40 | 41 | var ( 42 | cachedStorage []StorageAPI 43 | ) 44 | 45 | func Init() { 46 | if err := db.Instance.AutoMigrate(&Bucket{}); err != nil { 47 | log.Printf("Auto-migrate error: %v", err) 48 | } 49 | 50 | cachedStorage = []StorageAPI{} 51 | var buckets []Bucket 52 | err := db.Instance.Find(&buckets).Error 53 | if err != nil { 54 | panic(err) 55 | } 56 | if len(buckets) == 0 { 57 | log.Printf("No Storage Buckets found") 58 | // Create default bucket if DEFAULT_BUCKET_DIR is set 59 | if config.DEFAULT_BUCKET_DIR != "" { 60 | log.Printf("Creating default bucket in directory: %s", config.DEFAULT_BUCKET_DIR) 61 | bucket := Bucket{ 62 | Name: "Main", 63 | Path: config.DEFAULT_BUCKET_DIR, 64 | AssetPathPattern: config.DEFAULT_ASSET_PATH_PATTERN, 65 | StorageType: StorageTypeFile, 66 | } 67 | err := db.Instance.Create(&bucket).Error 68 | if err != nil { 69 | log.Fatalf("Error creating default bucket: %v", err) 70 | } 71 | log.Printf("Default bucket created!") 72 | // Reload buckets 73 | _ = db.Instance.Find(&buckets) 74 | } 75 | } 76 | for _, bucket := range buckets { 77 | log.Printf("Bucket: %+v\n", bucket) 78 | storage := NewStorage(&bucket) 79 | cachedStorage = append(cachedStorage, storage) 80 | } 81 | } 82 | 83 | func NewStorage(bucket *Bucket) StorageAPI { 84 | if bucket.StorageType == StorageTypeFile { 85 | return NewDiskStorage(bucket) 86 | } else if bucket.StorageType == StorageTypeS3 { 87 | return NewS3Storage(bucket) 88 | } else { 89 | panic(fmt.Sprintf("Storage type unavailable for Bucket %d", bucket.ID)) 90 | } 91 | } 92 | 93 | func (s *Storage) GetBucket() *Bucket { 94 | return &s.Bucket 95 | } 96 | 97 | func StorageFrom(bucket *Bucket) StorageAPI { 98 | for _, s := range cachedStorage { 99 | if s.GetBucket().ID == bucket.ID { 100 | return s 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | func GetDefaultStorage() StorageAPI { 107 | for _, s := range cachedStorage { 108 | if s.GetBucket().StorageType == StorageTypeFile { 109 | return s 110 | } 111 | } 112 | for _, s := range cachedStorage { 113 | return s 114 | } 115 | return nil 116 | } 117 | 118 | // 119 | // NOTE: All the functions below work on a local file 120 | // 121 | 122 | func (s *Storage) GetSize(path string) int64 { 123 | fi, err := os.Stat(s.GetFullPath(path)) 124 | if err != nil { 125 | return -1 126 | } 127 | return fi.Size() 128 | } 129 | 130 | func (s *Storage) Save(path string, reader io.Reader) (int64, error) { 131 | fileName := s.GetFullPath(path) 132 | if err := s.EnsureDirExists(filepath.Dir(fileName)); err != nil { 133 | return 0, err 134 | } 135 | file, err := os.Create(fileName) 136 | if err != nil { 137 | return 0, err 138 | } 139 | result, err := io.Copy(file, reader) 140 | file.Close() 141 | return result, err 142 | } 143 | 144 | func (s *Storage) Load(path string, writer io.Writer) (int64, error) { 145 | fileName := s.GetFullPath(path) 146 | file, err := os.Open(fileName) 147 | if err != nil { 148 | return 0, err 149 | } 150 | result, err := io.Copy(writer, file) 151 | file.Close() 152 | return result, err 153 | } 154 | 155 | func (s *Storage) Serve(path string, request *http.Request, writer http.ResponseWriter) { 156 | fileName := s.GetFullPath(path) 157 | http.ServeFile(writer, request, fileName) 158 | } 159 | 160 | func (s *Storage) Delete(path string) error { 161 | return os.Remove(s.GetFullPath(path)) 162 | } 163 | 164 | // 165 | // Proxy methods 166 | // 167 | 168 | func (s *Storage) GetFullPath(path string) string { 169 | return s.specifics.GetFullPath(path) 170 | } 171 | func (s *Storage) EnsureDirExists(dir string) error { 172 | return s.specifics.EnsureDirExists(dir) 173 | } 174 | func (s *Storage) EnsureLocalFile(path string) error { 175 | return s.specifics.EnsureLocalFile(path) 176 | } 177 | func (s *Storage) ReleaseLocalFile(path string) { 178 | s.specifics.ReleaseLocalFile(path) 179 | } 180 | func (s *Storage) DeleteRemoteFile(path string) error { 181 | return s.specifics.DeleteRemoteFile(path) 182 | } 183 | func (s *Storage) UpdateRemoteFile(path, mimeType string) error { 184 | return s.specifics.UpdateRemoteFile(path, mimeType) 185 | } 186 | -------------------------------------------------------------------------------- /templates/album_view.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{ .title }} 17 | 39 | 40 | 41 |
42 |
43 | 44 | 45 | 46 | 51 | 52 |
47 | {{ .name }}
48 | {{ .subtitle }}
49 | {{ .ownerName }} 50 |
53 |
54 |
55 | 56 |
57 | {{ range .assets }} 58 | {{ if eq .Type 1 }} 59 | 60 | {{ else if eq .MimeType "video/mp4" }} 61 | 64 | {{ end }} 65 | {{ end }} 66 |
67 | 68 | 69 | 70 | 71 | 72 | 79 | 80 | -------------------------------------------------------------------------------- /templates/upload_files.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | circled.me uploader 8 | 9 | 10 | 11 | 12 | 13 | 19 | 28 | 29 | 30 |
31 |

{{ .who }}'s upload

32 | 33 | 91 |
92 | 93 | -------------------------------------------------------------------------------- /utils/cache_router.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | const ( 10 | CacheNoCache = 0 11 | CacheCustom = -1 12 | ) 13 | 14 | type CacheRouter struct { 15 | CacheTime int // defaults to CacheNoCache = 0 16 | } 17 | 18 | func (cr *CacheRouter) Handler() gin.HandlerFunc { 19 | return func(c *gin.Context) { 20 | if cr.CacheTime != CacheCustom { 21 | if cr.CacheTime == CacheNoCache { 22 | c.Header("cache-control", "no-cache") 23 | } else { 24 | c.Header("cache-control", "private, max-age="+strconv.Itoa(cr.CacheTime)) 25 | } 26 | } 27 | c.Next() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /utils/error_log_middleware.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type errorLogWriter struct { 10 | gin.ResponseWriter 11 | gc *gin.Context 12 | } 13 | 14 | func (w errorLogWriter) Write(b []byte) (int, error) { 15 | status := w.gc.Writer.Status() 16 | if status >= 400 { 17 | log.Printf("[DEBUG ERROR]: Status %d, Body: %s", status, string(b)) 18 | } 19 | return w.ResponseWriter.Write(b) 20 | } 21 | 22 | // ErrorLogMiddleware doesn't work with GZIP 23 | func ErrorLogMiddleware(c *gin.Context) { 24 | blw := &errorLogWriter{gc: c, ResponseWriter: c.Writer} 25 | c.Writer = blw 26 | c.Next() 27 | } 28 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/sha512" 7 | "encoding/base64" 8 | "encoding/binary" 9 | "encoding/hex" 10 | "image" 11 | _ "image/gif" 12 | "image/jpeg" 13 | _ "image/png" 14 | "io" 15 | "math" 16 | "math/big" 17 | "strconv" 18 | "time" 19 | 20 | "github.com/nfnt/resize" 21 | ) 22 | 23 | // Sha512String hashes and encodes in hex the result 24 | func Sha512String(s string) string { 25 | hash := sha512.New() 26 | hash.Write([]byte(s)) 27 | return hex.EncodeToString(hash.Sum(nil)) 28 | } 29 | 30 | func Float32ArrayToByteArray(fa []float32) []byte { 31 | buf := bytes.Buffer{} 32 | _ = binary.Write(&buf, binary.LittleEndian, fa) 33 | return buf.Bytes() 34 | } 35 | 36 | func ByteArrayToFloat32Array(b []byte) (result []float32) { 37 | for i := 0; i < len(b); i += 4 { 38 | ui32 := uint32(b[i+0]) + 39 | uint32(b[i+1])<<8 + 40 | uint32(b[i+2])<<16 + 41 | uint32(b[i+3])<<24 42 | result = append(result, math.Float32frombits(ui32)) 43 | } 44 | return 45 | } 46 | 47 | func GetSeason(month time.Month, gpsLat *float64) string { 48 | if gpsLat == nil { 49 | return "" 50 | } 51 | if *gpsLat < 0 { 52 | month += 6 // add half an year for southern hemisphere 53 | } 54 | if month >= 3 && month <= 5 { 55 | return "Spring" 56 | } else if month >= 6 && month <= 8 { 57 | return "Summer" 58 | } else if month >= 9 && month <= 11 { 59 | return "Autumn/Fall" 60 | } 61 | return "Winter" 62 | } 63 | 64 | func GetDatesString(min, max int64) string { 65 | if min == 0 || max == 0 { 66 | return "empty :(" 67 | } 68 | minString := time.Unix(min, 0).Format("2 Jan 2006") 69 | if max-min <= 86400 { 70 | return minString 71 | } 72 | maxString := time.Unix(max, 0).Format("2 Jan 2006") 73 | return minString + " - " + maxString 74 | } 75 | 76 | func RandSalt(saltSize int) string { 77 | b := make([]byte, saltSize) 78 | _, err := rand.Read(b) 79 | if err != nil { 80 | panic(err) 81 | } 82 | return base64.StdEncoding.EncodeToString(b) 83 | } 84 | 85 | func Rand16BytesToBase62() string { 86 | buf := make([]byte, 16) 87 | _, err := rand.Read(buf) 88 | if err != nil { 89 | panic(err) 90 | } 91 | var i big.Int 92 | return i.SetBytes(buf).Text(62) 93 | } 94 | 95 | func Rand8BytesToBase62() string { 96 | buf := make([]byte, 8) 97 | _, err := rand.Read(buf) 98 | if err != nil { 99 | panic(err) 100 | } 101 | var i big.Int 102 | return i.SetBytes(buf).Text(62) 103 | } 104 | 105 | type ImageThumbConverted struct { 106 | ThumbSize int64 107 | NewX uint16 108 | NewY uint16 109 | OldX uint16 110 | OldY uint16 111 | } 112 | 113 | func CreateThumb(size uint, reader io.Reader, writer io.Writer) (result ImageThumbConverted, err error) { 114 | image, _, err := image.Decode(reader) 115 | if err != nil { 116 | return result, err 117 | } 118 | var newBuf bytes.Buffer 119 | newImage := resize.Thumbnail(size, size, image, resize.Lanczos3) 120 | if err = jpeg.Encode(&newBuf, newImage, &jpeg.Options{Quality: 90}); err != nil { 121 | return 122 | } 123 | imageRect := newImage.Bounds().Size() 124 | result.NewX = uint16(imageRect.X) 125 | result.NewY = uint16(imageRect.Y) 126 | 127 | imageRect = image.Bounds().Size() 128 | result.OldX = uint16(imageRect.X) 129 | result.OldY = uint16(imageRect.Y) 130 | 131 | result.ThumbSize, err = io.Copy(writer, &newBuf) 132 | return 133 | } 134 | 135 | func StringToFloat64Ptr(in string) *float64 { 136 | f, _ := strconv.ParseFloat(in, 64) 137 | return &f 138 | } 139 | 140 | func StringToUInt16(in string) uint16 { 141 | i, _ := strconv.ParseUint(in, 10, 16) 142 | return uint16(i) 143 | } 144 | -------------------------------------------------------------------------------- /web/album.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "server/db" 6 | "server/handlers" 7 | "server/utils" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func AlbumView(c *gin.Context) { 13 | token := c.Param("token") 14 | rows, err := db.Instance. 15 | Table("album_shares"). 16 | Select("album_id, albums.name, owners.name, hide_original, hero_asset_id"). 17 | Where("token = ? and (expires_at is null or expires_at=0 or expires_at>"+db.TimestampFunc+")", token). 18 | Joins("join albums on album_shares.album_id = albums.id"). 19 | Joins("join users on album_shares.user_id = users.id"). 20 | Joins("join users as owners on albums.user_id = owners.id"). 21 | Rows() 22 | 23 | if err != nil { 24 | c.JSON(http.StatusInternalServerError, handlers.DBError1Response) 25 | return 26 | } 27 | // Get album info 28 | var albumId uint64 29 | var albumName string 30 | var userName string 31 | var hideOriginal int 32 | var heroAssetID *uint64 33 | if rows.Next() { 34 | if err = rows.Scan(&albumId, &albumName, &userName, &hideOriginal, &heroAssetID); err != nil { 35 | c.JSON(http.StatusInternalServerError, handlers.Response{Error: "something went really wrong"}) 36 | rows.Close() 37 | return 38 | } 39 | } 40 | rows.Close() 41 | // Get all assets for the album 42 | rows, err = db.Instance. 43 | Table("album_assets"). 44 | Select(handlers.AssetsSelectClause). 45 | Joins("join assets on album_assets.asset_id = assets.id"). 46 | Joins("left join favourite_assets on favourite_assets.asset_id = assets.id"). 47 | Joins(handlers.LeftJoinForLocations). 48 | Where("album_assets.album_id = ? and assets.deleted=0 and assets.size>0 and assets.thumb_size>0", albumId). 49 | Order("assets.created_at ASC"). 50 | Rows() 51 | 52 | if err != nil { 53 | c.JSON(http.StatusInternalServerError, handlers.DBError1Response) 54 | return 55 | } 56 | defer rows.Close() 57 | result := handlers.LoadAssetsFromRows(c, rows) 58 | if result == nil { 59 | c.HTML(http.StatusOK, "album_view.tmpl", gin.H{}) 60 | return 61 | } 62 | var createdMin, createdMax uint64 63 | createdMin = 100000000000 64 | for i, row := range *result { 65 | // Hide private information 66 | (*result)[i].Owner = 0 67 | (*result)[i].DID = "" 68 | if hideOriginal > 0 { 69 | (*result)[i].GpsLat = nil 70 | (*result)[i].GpsLong = nil 71 | (*result)[i].Location = nil 72 | } 73 | if createdMax < row.Created { 74 | createdMax = row.Created 75 | } 76 | if createdMin > row.Created { 77 | createdMin = row.Created 78 | } 79 | } 80 | downloadParam := "download" 81 | if hideOriginal > 0 { 82 | downloadParam = "thumb" 83 | } 84 | json := gin.H{ 85 | "ownerName": "@" + userName, 86 | "subtitle": utils.GetDatesString(int64(createdMin), int64(createdMax)), 87 | "name": albumName, 88 | "assets": result, 89 | "downloadParam": downloadParam, 90 | "heroAssetID": 0, 91 | } 92 | if heroAssetID != nil { 93 | json["heroAssetID"] = *heroAssetID 94 | } else if len(*result) > 0 { 95 | json["heroAssetID"] = (*result)[0].ID 96 | } 97 | if c.Query("format") == "json" { 98 | c.JSON(http.StatusOK, json) 99 | return 100 | } 101 | // json["baseURL"] = config.SELF_BASE_URL + c.Request.URL.String() 102 | json["baseURL"] = c.Request.URL.String() 103 | c.HTML(http.StatusOK, "album_view.tmpl", json) 104 | } 105 | 106 | func AlbumAssetView(c *gin.Context) { 107 | token := c.Param("token") 108 | r := handlers.AssetFetchRequest{} 109 | err := c.ShouldBindQuery(&r) 110 | if err != nil { 111 | c.JSON(http.StatusBadRequest, handlers.Response{Error: err.Error()}) 112 | return 113 | } 114 | // Verify we have permission to view this asset 115 | hideOriginalCond := "" 116 | if r.Download == 1 { 117 | hideOriginalCond = " and album_shares.hide_original = 0" 118 | } 119 | rows, err := db.Instance.Table("album_shares").Select("album_assets.album_id"). 120 | Where("token = ? and "+ 121 | "album_assets.asset_id = ? and "+ 122 | "(expires_at is null or expires_at=0 or expires_at>"+db.TimestampFunc+")"+ 123 | hideOriginalCond, token, r.ID). 124 | Joins("join album_assets on album_shares.album_id = album_assets.album_id").Rows() 125 | 126 | if err != nil { 127 | c.JSON(http.StatusInternalServerError, handlers.Response{Error: "something went wrong"}) 128 | return 129 | } 130 | defer rows.Close() 131 | if !rows.Next() { 132 | c.JSON(http.StatusNotFound, handlers.Response{Error: "something went totally wrong"}) 133 | return 134 | } 135 | // Return the asset 136 | handlers.RealAssetFetch(c, 0) 137 | } 138 | -------------------------------------------------------------------------------- /web/call.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "server/auth" 7 | "server/config" 8 | "server/models" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func CallView(c *gin.Context) { 14 | id := c.Param("id") 15 | vc, err := models.VideoCallByID(id) 16 | if err != nil { 17 | c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal Server Error"}) 18 | return 19 | } 20 | if vc.ID == "" { 21 | c.JSON(http.StatusNotFound, gin.H{"error": "Not Found"}) 22 | return 23 | } 24 | // Load the user session by setting the token cookie manually from the query 25 | sessionToken := c.Query("token") 26 | c.Request.Header.Add("Cookie", "token="+sessionToken) 27 | session := auth.LoadSession(c) 28 | user := session.User() 29 | log.Printf("User %d is trying to join call %s", user.ID, vc.ID) 30 | 31 | c.HTML(http.StatusOK, "call_view.tmpl", gin.H{ 32 | "id": vc.ID, 33 | "wsQuery": "token=" + sessionToken, 34 | "turnIP": config.TURN_SERVER_IP, 35 | "turnPort": config.TURN_SERVER_PORT, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /web/upload.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "server/db" 7 | "server/handlers" 8 | "server/models" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | type UploadConfirmation struct { 16 | ID uint64 `json:"id" binding:"required"` // Local DB ID 17 | Size int64 `json:"size" binding:"required"` 18 | MimeType string `json:"mime_type" binding:""` 19 | } 20 | 21 | type NewAssetResponse struct { 22 | ID uint64 `json:"id"` 23 | URL string `json:"url"` 24 | } 25 | 26 | func getUploadRequest(c *gin.Context) (req models.UploadRequest, err error) { 27 | token := c.Param("token") 28 | // Valid for 3 hours 29 | err = db.Instance. 30 | Where("token = ? and created_at >= "+db.TimestampFunc+"-3*3600", token). 31 | Preload("User"). 32 | Find(&req).Error 33 | return 34 | } 35 | 36 | func UploadRequestProcess(c *gin.Context) { 37 | req, err := getUploadRequest(c) 38 | if err != nil || req.ID == 0 { 39 | fmt.Println(err) 40 | c.JSON(http.StatusInternalServerError, handlers.Response{Error: "something went wrong"}) 41 | return 42 | } 43 | handlers.BackupLocalAsset(req.UserID, c) 44 | } 45 | 46 | func UploadRequestView(c *gin.Context) { 47 | req, err := getUploadRequest(c) 48 | if err != nil || req.ID == 0 { 49 | fmt.Println(err) 50 | c.JSON(http.StatusInternalServerError, handlers.Response{Error: "something went wrong"}) 51 | return 52 | } 53 | // Some cleanup 54 | db.Instance.Exec("delete from upload_requests where created_at < " + db.TimestampFunc + "-7200") 55 | 56 | c.HTML(http.StatusOK, "upload_files.tmpl", gin.H{ 57 | "who": "@" + req.User.Name, 58 | }) 59 | } 60 | 61 | func UploadRequestNewURL(c *gin.Context) { 62 | req, err := getUploadRequest(c) 63 | if err != nil || req.ID == 0 { 64 | c.JSON(http.StatusInternalServerError, handlers.Response{Error: "something went wrong"}) 65 | return 66 | } 67 | prefix := req.Token 68 | if len(prefix) > 10 { 69 | prefix = prefix[:10] 70 | } 71 | // TODO: user NewMetadata here too instead of all this 72 | if req.User.HasNoRemainingQuota() { 73 | c.JSON(http.StatusForbidden, handlers.Response{Error: "Quota exceeded"}) 74 | return 75 | } 76 | asset := models.Asset{ 77 | UserID: req.UserID, 78 | BucketID: *req.User.BucketID, 79 | RemoteID: prefix + "_" + strconv.FormatInt(time.Now().UnixNano(), 10), 80 | Name: c.Query("name"), 81 | CreatedAt: time.Now().UnixMilli() / 1000, 82 | } 83 | result := db.Instance.Create(&asset) 84 | if result.Error != nil { 85 | c.JSON(http.StatusInternalServerError, handlers.DBError1Response) 86 | return 87 | } 88 | if db.Instance.Preload("Bucket").Preload("User").First(&asset).Error != nil { 89 | c.JSON(http.StatusInternalServerError, handlers.DBError1Response) 90 | return 91 | } 92 | response := NewAssetResponse{ 93 | ID: asset.ID, 94 | URL: asset.CreateUploadURI(false, req.Token), // TODO: Thumb? 95 | } 96 | // Save as Paths are updated 97 | if db.Instance.Save(&asset).Error != nil { 98 | c.JSON(http.StatusInternalServerError, handlers.DBError2Response) 99 | return 100 | } 101 | c.JSON(http.StatusOK, response) 102 | } 103 | 104 | func UploadRequestConfirm(c *gin.Context) { 105 | req, err := getUploadRequest(c) 106 | if err != nil || req.ID == 0 { 107 | c.JSON(http.StatusInternalServerError, handlers.Response{Error: "something went wrong"}) 108 | return 109 | } 110 | var r UploadConfirmation 111 | err = c.ShouldBindJSON(&r) 112 | if err != nil { 113 | c.JSON(http.StatusBadRequest, handlers.Response{Error: err.Error()}) 114 | return 115 | } 116 | asset := models.Asset{ 117 | ID: r.ID, 118 | UserID: req.UserID, 119 | } 120 | result := db.Instance.First(&asset) 121 | if result.Error != nil { 122 | c.JSON(http.StatusInternalServerError, handlers.DBError1Response) 123 | return 124 | } 125 | asset.Size = r.Size 126 | asset.MimeType = r.MimeType 127 | db.Instance.Updates(&asset) 128 | c.JSON(http.StatusOK, handlers.OKResponse) 129 | } 130 | 131 | func DisallowRobots(c *gin.Context) { 132 | // c.String(http.StatusOK, "User-agent: *\nDisallow: /\n") 133 | c.String(http.StatusOK, "User-agent: facebookexternalhit\nAllow: /w/*\n\n"+"User-agent: *\nDisallow: /\n") 134 | } 135 | -------------------------------------------------------------------------------- /webrtc/room.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "server/utils" 7 | "sync" 8 | "time" 9 | 10 | "github.com/gorilla/websocket" 11 | ) 12 | 13 | const ( 14 | roomAbandonedTimeout = 1 * time.Minute 15 | userAbandonedTimeout = 20 * time.Second 16 | ) 17 | 18 | type Client struct { 19 | conn *websocket.Conn 20 | ID string 21 | UserID uint64 22 | lastSeen time.Time 23 | } 24 | 25 | type Room struct { 26 | ID string 27 | mutex sync.RWMutex 28 | clients []*Client 29 | } 30 | 31 | var ( 32 | rooms = make(map[string]*Room) 33 | lastSeen = make(map[string]time.Time) 34 | mutex sync.Mutex 35 | ) 36 | 37 | func init() { 38 | go backgroundCleanup() 39 | } 40 | 41 | func ValidateRoom(room string) bool { 42 | mutex.Lock() 43 | _, exists := rooms[room] 44 | mutex.Unlock() 45 | return exists 46 | } 47 | 48 | func backgroundCleanup() { 49 | for { 50 | // Check for abandoned clients every 20 seconds 51 | mutex.Lock() 52 | checkAbandonedRooms() 53 | tmpRooms := make(map[string]*Room, len(rooms)) 54 | for id, room := range rooms { 55 | tmpRooms[id] = room 56 | } 57 | mutex.Unlock() 58 | for _, room := range tmpRooms { 59 | abandoned := []string{} 60 | room.CheckAbandonedClients(func(clientId string) { 61 | abandoned = append(abandoned, clientId) 62 | }) 63 | for _, clientId := range abandoned { 64 | room.Broadcast(clientId, map[string]interface{}{ 65 | "type": "left", 66 | "from": clientId, 67 | }) 68 | log.Printf("Client %s abandoned room\n", clientId) 69 | } 70 | } 71 | time.Sleep(userAbandonedTimeout) 72 | } 73 | } 74 | 75 | // checkAbandonedRooms checks for abandoned rooms and removes them (assumes the main mutex is locked) 76 | func checkAbandonedRooms() { 77 | for id, last := range lastSeen { 78 | if time.Since(last) > roomAbandonedTimeout { 79 | log.Printf("Room %s has been abandoned\n", id) 80 | delete(rooms, id) 81 | delete(lastSeen, id) 82 | } 83 | } 84 | } 85 | 86 | func GetRoom(id string) (room *Room, isNew bool) { 87 | mutex.Lock() 88 | defer mutex.Unlock() 89 | if room, exists := rooms[id]; exists { 90 | lastSeen[id] = time.Now() 91 | return room, false 92 | } 93 | room = &Room{ 94 | ID: id, 95 | } 96 | rooms[id] = room 97 | lastSeen[id] = time.Now() 98 | return room, true 99 | } 100 | 101 | func (r *Room) CheckAbandonedClients(callback func(string)) { 102 | r.mutex.Lock() 103 | log.Printf("Checking for abandoned clients in room %s\n", r.ID) 104 | // Iterate over clients in reverse order to remove abandoned clients as there might be more than one 105 | for i := len(r.clients) - 1; i >= 0; i-- { 106 | client := r.clients[i] 107 | log.Printf("Client %s, last seen %v ago\n", client.ID, time.Since(client.lastSeen)) 108 | if time.Since(client.lastSeen) > userAbandonedTimeout { 109 | callback(client.ID) 110 | r.clients = append(r.clients[:i], r.clients[i+1:]...) 111 | break 112 | } 113 | } 114 | r.mutex.Unlock() 115 | } 116 | 117 | func (r *Room) SeenClient(client *Client) { 118 | // Update the last seen time for the room 119 | mutex.Lock() 120 | lastSeen[r.ID] = time.Now() 121 | mutex.Unlock() 122 | // Update the last seen time for the client 123 | r.mutex.RLock() 124 | defer r.mutex.RUnlock() 125 | for _, c := range r.clients { 126 | if c == client { 127 | c.lastSeen = time.Now() 128 | break 129 | } 130 | } 131 | } 132 | 133 | func (r *Room) SetUpClient(conn *websocket.Conn, id string, userID uint64) (client *Client, isNew bool, numClients int) { 134 | // Is the client already in the room? 135 | for _, c := range r.clients { 136 | if c.ID == id { 137 | // Update the connection and last seen time 138 | c.conn = conn 139 | c.lastSeen = time.Now() 140 | return c, false, len(r.clients) 141 | } 142 | } 143 | // Add new client to the room 144 | client = &Client{ 145 | conn: conn, 146 | ID: utils.Rand8BytesToBase62(), 147 | lastSeen: time.Now(), 148 | UserID: userID, 149 | } 150 | r.clients = append(r.clients, client) 151 | return client, true, len(r.clients) 152 | } 153 | 154 | func (r *Room) RemoveClient(client *Client) { 155 | r.mutex.Lock() 156 | for i, c := range r.clients { 157 | if c == client { 158 | r.clients = append(r.clients[:i], r.clients[i+1:]...) 159 | break 160 | } 161 | } 162 | r.mutex.Unlock() 163 | } 164 | 165 | func (r *Room) Broadcast(from string, data interface{}) { 166 | message, _ := json.Marshal(data) 167 | r.mutex.RLock() 168 | for _, client := range r.clients { 169 | if client.ID != from { 170 | log.Printf("Sending (brodacast) message to %s: %s\n", client.ID, string(message)) 171 | client.conn.WriteMessage(websocket.TextMessage, message) 172 | } 173 | } 174 | r.mutex.RUnlock() 175 | } 176 | 177 | func (r *Room) MessageTo(clientId string, data interface{}) { 178 | message, _ := json.Marshal(data) 179 | r.mutex.RLock() 180 | for _, client := range r.clients { 181 | if client.ID == clientId { 182 | log.Printf("Sending (private) message to %s: %s\n", client.ID, string(message)) 183 | client.conn.WriteMessage(websocket.TextMessage, message) 184 | break 185 | } 186 | } 187 | r.mutex.RUnlock() 188 | } 189 | 190 | func (r *Room) GetOnlineUsers() (result []uint64) { 191 | r.mutex.RLock() 192 | for _, client := range r.clients { 193 | result = append(result, client.UserID) 194 | } 195 | r.mutex.RUnlock() 196 | return 197 | } 198 | -------------------------------------------------------------------------------- /webrtc/turn.go: -------------------------------------------------------------------------------- 1 | package webrtc 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strconv" 7 | 8 | "log" 9 | 10 | "github.com/pion/turn/v2" 11 | ) 12 | 13 | const ( 14 | realm = "circled.me" 15 | ) 16 | 17 | type TurnServer struct { 18 | Port int 19 | PublicIP string 20 | TrafficMinPort int 21 | TrafficMaxPort int 22 | AuthFunc func(userToken string) bool 23 | server *turn.Server 24 | } 25 | 26 | func (ts *TurnServer) Start() (err error) { 27 | udpListener, err := net.ListenPacket("udp4", "0.0.0.0:"+strconv.Itoa(ts.Port)) 28 | if err != nil { 29 | return fmt.Errorf("Failed to create TURN server listener: %s", err) 30 | } 31 | 32 | ts.server, err = turn.NewServer(turn.ServerConfig{ 33 | Realm: realm, 34 | AuthHandler: func(username string, realm string, srcAddr net.Addr) ([]byte, bool) { // nolint: revive 35 | if ts.AuthFunc(username) { 36 | return turn.GenerateAuthKey(username, realm, username), true 37 | } 38 | return nil, false 39 | }, 40 | // PacketConnConfigs is a list of UDP Listeners and the configuration around them 41 | PacketConnConfigs: []turn.PacketConnConfig{ 42 | { 43 | PacketConn: udpListener, 44 | RelayAddressGenerator: &turn.RelayAddressGeneratorPortRange{ 45 | RelayAddress: net.ParseIP(ts.PublicIP), // Claim that we are listening on IP passed by user (This should be your Public IP) 46 | Address: "0.0.0.0", // But actually be listening on every interface 47 | MinPort: uint16(ts.TrafficMinPort), 48 | MaxPort: uint16(ts.TrafficMaxPort), 49 | }, 50 | }, 51 | }, 52 | }) 53 | return 54 | } 55 | 56 | func (ts *TurnServer) Stop() { 57 | if err := ts.server.Close(); err != nil { 58 | log.Printf("Closing TURN server error: %v", err) 59 | } 60 | } 61 | --------------------------------------------------------------------------------