package controller import ( "encoding/base64" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/heicode/manager/common" "github.com/heicode/manager/i18n" "github.com/heicode/manager/model" "github.com/heicode/manager/setting/operation_setting" ) // Public DTO sent to the web dashboard. Never includes Key (the raw sk- // value) — that's only returned once at pair time. Subsequent listings // show only metadata so a curious admin can't reseed someone's device // just by reading the API response. type deviceListItem struct { Id int `json:"id"` DeviceId string `json:"device_id"` DeviceName string `json:"device_name"` DevicePlatform string `json:"device_platform"` DeviceAppVersion string `json:"device_app_version"` DeviceBoundAt int64 `json:"device_bound_at"` DeviceLastUsedAt int64 `json:"device_last_used_at"` DeviceLastSeenIp string `json:"device_last_seen_ip"` Status int `json:"status"` RevokedAt int64 `json:"revoked_at"` RevokedReason string `json:"revoked_reason"` } func toDeviceListItem(t *model.Token) deviceListItem { deviceId := "" if t.DeviceId != nil { deviceId = *t.DeviceId } return deviceListItem{ Id: t.Id, DeviceId: deviceId, DeviceName: t.DeviceName, DevicePlatform: t.DevicePlatform, DeviceAppVersion: t.DeviceAppVersion, DeviceBoundAt: t.DeviceBoundAt, DeviceLastUsedAt: t.DeviceLastUsedAt, DeviceLastSeenIp: t.DeviceLastSeenIp, Status: t.Status, RevokedAt: t.RevokedAt, RevokedReason: t.RevokedReason, } } // ListUserDevices serves GET /api/devices — the "Devices" tab on the // user's Profile page. Each row maps 1:1 to a device-bound token row. func ListUserDevices(c *gin.Context) { userId := c.GetInt("id") if userId == 0 { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "unauthorized"}) return } tokens, err := model.GetUserDeviceBoundTokens(userId) if err != nil { common.SysLog("ListUserDevices: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgDatabaseError), }) return } items := make([]deviceListItem, 0, len(tokens)) for _, t := range tokens { items = append(items, toDeviceListItem(t)) } c.JSON(http.StatusOK, gin.H{"success": true, "data": items}) } type pairDeviceRequest struct { DeviceId string `json:"device_id"` // client-generated UUID v4 PublicKey string `json:"public_key"` // base64 32-byte Ed25519 pubkey Fingerprint string `json:"fingerprint"` // sha256 hex of HWID + hostname DeviceName string `json:"device_name"` // free-form, e.g. "Chen's MacBook" Platform string `json:"platform"` // darwin / windows / linux AppVersion string `json:"app_version"` // cc-haha version at pair time } // PairDevice serves POST /api/devices/pair — the very first thing a // new cc-haha install does after the user logs into Heicode. The // session cookie (set by /api/user/login) provides the user identity. // We create a NEW hidden token row whose key the client must store in // OS keychain — that's the bearer for subsequent calls, paired with a // signature header. func PairDevice(c *gin.Context) { userId := c.GetInt("id") if userId == 0 { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "unauthorized"}) return } var req pairDeviceRequest if err := common.UnmarshalBodyReusable(c, &req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgInvalidParams), }) return } if !isValidPairRequest(req) { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgInvalidParams), }) return } // Per-user device cap. Enforce BEFORE inserting so racing pair calls // from a malicious script can't sneak past. count, err := model.CountUserDeviceBoundTokens(userId) if err != nil { common.SysLog("PairDevice count: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgDatabaseError), }) return } if int(count) >= operation_setting.GetMaxDevicesPerUser() { c.JSON(http.StatusForbidden, gin.H{ "success": false, "message": "device limit reached", }) return } // Refuse if same device_id already paired for this user — caller // should DELETE first if they want to re-pair. var existing model.Token dup := model.DB.Where("user_id = ? AND device_id = ?", userId, req.DeviceId). First(&existing).Error if dup == nil { c.JSON(http.StatusConflict, gin.H{ "success": false, "message": "device already paired", }) return } rawKey, err := common.GenerateKey() if err != nil { common.SysLog("PairDevice GenerateKey: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgDatabaseError), }) return } now := common.GetTimestamp() nowMs := now * 1000 deviceIdCopy := req.DeviceId pubkeyCopy := req.PublicKey tok := model.Token{ UserId: userId, Name: deviceNameFromRequest(req), Key: rawKey, Status: common.TokenStatusEnabled, CreatedTime: now, AccessedTime: now, ExpiredTime: -1, // never naturally; signature validity is the gate UnlimitedQuota: false, HideFromUserUI: true, // device tokens only show on Devices page DeviceId: &deviceIdCopy, DevicePubkey: &pubkeyCopy, DeviceFingerprint: req.Fingerprint, DeviceName: req.DeviceName, DevicePlatform: req.Platform, DeviceAppVersion: req.AppVersion, DeviceBoundAt: nowMs, RequireDeviceBinding: true, } if err := tok.Insert(); err != nil { common.SysLog("PairDevice Insert: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgDatabaseError), }) return } // Return the raw key ONCE. Client puts it in OS keychain immediately // and never persists to providers.json. Manager log only records the // masked form via the token controller's existing helpers. c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "id": tok.Id, "device_id": deviceIdCopy, "sk": "sk-" + rawKey, "device_bound_at": nowMs, }, }) } // RevokeUserDevice serves DELETE /api/devices/:id — flips the device's // token to disabled. Client side discards the keychain entry and // prompts for re-pair. func RevokeUserDevice(c *gin.Context) { userId := c.GetInt("id") tokenId, err := strconv.Atoi(c.Param("id")) if err != nil || tokenId <= 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "bad id"}) return } // Look up + ownership check in one query to prevent users from // guessing token ids belonging to other users. var tok model.Token q := model.DB.Where("id = ? AND user_id = ?", tokenId, userId).First(&tok) if q.Error != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"}) return } if tok.DevicePubkey == nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "not a device-bound token"}) return } if err := model.RevokeDevice(tok.Id, "user_initiated"); err != nil { common.SysLog("RevokeUserDevice: " + err.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgDatabaseError), }) return } c.JSON(http.StatusOK, gin.H{"success": true}) } type renameDeviceRequest struct { DeviceName string `json:"device_name"` } // RenameUserDevice serves PATCH /api/devices/:id — lets the user update // the friendly label without re-pairing. Trims to 64 chars to match the // DB column. func RenameUserDevice(c *gin.Context) { userId := c.GetInt("id") tokenId, err := strconv.Atoi(c.Param("id")) if err != nil || tokenId <= 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "bad id"}) return } var req renameDeviceRequest if err := common.UnmarshalBodyReusable(c, &req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgInvalidParams), }) return } name := strings.TrimSpace(req.DeviceName) if len(name) > 64 { name = name[:64] } res := model.DB.Model(&model.Token{}). Where("id = ? AND user_id = ? AND device_pubkey IS NOT NULL", tokenId, userId). Update("device_name", name) if res.Error != nil { common.SysLog("RenameUserDevice: " + res.Error.Error()) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": common.TranslateMessage(c, i18n.MsgDatabaseError), }) return } if res.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // --- validation helpers --- func isValidPairRequest(r pairDeviceRequest) bool { if r.DeviceId == "" || r.PublicKey == "" || r.Fingerprint == "" { return false } if len(r.DeviceId) > 64 { return false } if len(r.Fingerprint) != 64 { // sha256 hex return false } // Ed25519 pubkey must be exactly 32 bytes once base64-decoded. pk, err := base64.StdEncoding.DecodeString(r.PublicKey) if err != nil || len(pk) != 32 { return false } if len(r.DeviceName) > 64 || len(r.Platform) > 16 || len(r.AppVersion) > 32 { return false } return true } func deviceNameFromRequest(r pairDeviceRequest) string { // `Token.Name` is what shows up in legacy admin tools and consume // logs. Make it visually obvious these rows are devices, not // user-created API keys, so admins can tell them apart at a glance. label := strings.TrimSpace(r.DeviceName) if label == "" { label = "device" } return "device:" + label }