Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ p, anonymous, *, /api/get-issue
p, anonymous, *, /api/add-issue
p, anonymous, *, /api/update-issue
p, anonymous, *, /api/delete-issue
p, anonymous, *, /api/get-store-favorite-status
p, anonymous, *, /api/toggle-store-favorite
p, anonymous, *, /api/get-favored-stores
p, anonymous, *, /api/get-hub-stores
p, anonymous, *, /api/fork-store
p, anonymous, *, /api/get-user-info

g, admin, user
g, user, anonymous
Expand Down
15 changes: 15 additions & 0 deletions controllers/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,21 @@ func (c *ApiController) ForkStore() {
return
}

if src.Owner == targetOwner {
c.ResponseError(c.T("store:You cannot fork your own agent"))
return
}

alreadyForked, err := object.HasUserForkedStore(targetOwner, src.Owner, src.Name)
if err != nil {
c.ResponseError(err.Error())
return
}
if alreadyForked {
c.ResponseError(c.T("store:You have already forked this agent"))
return
}

newStore, err := object.ForkStore(src.Owner, src.Name, targetOwner)
if err != nil {
c.ResponseError(err.Error())
Expand Down
159 changes: 159 additions & 0 deletions controllers/store_favorite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package controllers

import (
"encoding/json"

"github.com/the-open-agent/openagent/object"
"github.com/the-open-agent/openagent/util"
)

type toggleFavoriteForm struct {
Type string `json:"type"`
StoreOwner string `json:"storeOwner"`
StoreName string `json:"storeName"`
}

// ToggleStoreFavorite stars/unstars or watches/unwatches a store for the current user.
// @router /toggle-store-favorite [post]
func (c *ApiController) ToggleStoreFavorite() {
user, ok := c.RequireSignedIn()
if !ok {
return
}
if util.IsAnonymousUserByUsername(user) {
c.ResponseError(c.T("auth:Please sign in first"))
return
}

var form toggleFavoriteForm
err := json.Unmarshal(c.Ctx.Input.RequestBody, &form)
if err != nil {
c.ResponseError(err.Error())
return
}
if !object.IsValidFavoriteType(form.Type) {
c.ResponseError("invalid favorite type")
return
}
if form.StoreOwner == "" || form.StoreName == "" {
c.ResponseError("storeOwner and storeName are required")
return
}

store, err := object.GetStore(util.GetIdFromOwnerAndName(form.StoreOwner, form.StoreName))
if err != nil {
c.ResponseError(err.Error())
return
}
if store == nil {
c.ResponseError(c.T("store:The agent does not exist"))
return
}

favorited, err := object.ToggleStoreFavorite(user, form.Type, form.StoreOwner, form.StoreName)
if err != nil {
c.ResponseError(err.Error())
return
}
count, err := object.GetStoreFavoriteCount(form.Type, form.StoreOwner, form.StoreName)
if err != nil {
c.ResponseError(err.Error())
return
}

c.ResponseOk(map[string]interface{}{"favorited": favorited, "count": count})
}

// GetFavoredStores returns the current user's starred or watched stores.
// @router /get-favored-stores [get]
func (c *ApiController) GetFavoredStores() {
user, ok := c.RequireSignedIn()
if !ok {
return
}

favoriteType := c.Input().Get("type")
if !object.IsValidFavoriteType(favoriteType) {
c.ResponseError("invalid favorite type")
return
}

stores, err := object.GetFavoredStores(user, favoriteType)
if err != nil {
c.ResponseError(err.Error())
return
}

c.ResponseOk(object.GetMaskedStores(stores, c.GetSessionUser()))
}

// GetStoreFavoriteStatus returns star/watch counts (public) and, for a signed-in
// user, whether they starred/watched the store and whether they already forked it.
// @router /get-store-favorite-status [get]
func (c *ApiController) GetStoreFavoriteStatus() {
storeOwner := c.Input().Get("storeOwner")
storeName := c.Input().Get("storeName")
if storeOwner == "" || storeName == "" {
c.ResponseError("storeOwner and storeName are required")
return
}

starCount, err := object.GetStoreFavoriteCount(object.FavoriteTypeStar, storeOwner, storeName)
if err != nil {
c.ResponseError(err.Error())
return
}
watchCount, err := object.GetStoreFavoriteCount(object.FavoriteTypeWatch, storeOwner, storeName)
if err != nil {
c.ResponseError(err.Error())
return
}

result := map[string]interface{}{
"starCount": starCount,
"watchCount": watchCount,
"starred": false,
"watched": false,
"hasForked": false,
"isOwner": false,
}

user := c.GetSessionUsername()
if user != "" && !util.IsAnonymousUserByUsername(user) {
starred, err := object.IsStoreFavorited(user, object.FavoriteTypeStar, storeOwner, storeName)
if err != nil {
c.ResponseError(err.Error())
return
}
watched, err := object.IsStoreFavorited(user, object.FavoriteTypeWatch, storeOwner, storeName)
if err != nil {
c.ResponseError(err.Error())
return
}
hasForked, err := object.HasUserForkedStore(user, storeOwner, storeName)
if err != nil {
c.ResponseError(err.Error())
return
}
result["starred"] = starred
result["watched"] = watched
result["hasForked"] = hasForked
result["isOwner"] = user == storeOwner
}

c.ResponseOk(result)
}
3 changes: 3 additions & 0 deletions i18n/locales/en/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@
},
"store": {
"Cannot delete the default store": "Cannot delete the default store",
"The agent does not exist": "The agent does not exist",
"You cannot fork your own agent": "You cannot fork your own agent",
"You have already forked this agent": "You have already forked this agent",
"given that there must be one default store in OpenAgent, you cannot set this store to non-default. You can directly set another store as default": "given that there must be one default store in OpenAgent, you cannot set this store to non-default. You can directly set another store as default"
},
"stt": {
Expand Down
3 changes: 3 additions & 0 deletions i18n/locales/zh/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@
},
"store": {
"Cannot delete the default store": "无法删除默认存储库",
"The agent does not exist": "智能体不存在",
"You cannot fork your own agent": "无法复刻自己的智能体",
"You have already forked this agent": "你已经复刻过这个智能体了",
"given that there must be one default store in OpenAgent, you cannot set this store to non-default. You can directly set another store as default": "OpenAgent中必须保留至少一个默认存储,因此无法将当前存储设为非默认。您可以直接将另一个存储设为默认。"
},
"stt": {
Expand Down
5 changes: 5 additions & 0 deletions object/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,4 +422,9 @@ func (a *Adapter) createTable() {
if err != nil {
panic(err)
}

err = a.engine.Sync2(new(StoreFavorite))
if err != nil {
panic(err)
}
}
6 changes: 6 additions & 0 deletions object/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,12 @@ func DeleteStore(store *Store) (bool, error) {
return false, err
}

_, err = session.Where("store_owner = ? and store_name = ?", store.Owner, store.Name).Delete(&StoreFavorite{})
if err != nil {
session.Rollback()
return false, err
}

err = session.Commit()
if err != nil {
return false, err
Expand Down
121 changes: 121 additions & 0 deletions object/store_favorite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2026 The OpenAgent Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package object

import (
"github.com/the-open-agent/openagent/util"
)

const (
FavoriteTypeStar = "star"
FavoriteTypeWatch = "watch"
)

// StoreFavorite records that a user has starred or watched a store (agent).
// One row per (user, type, target store). Star and Watch share this table,
// distinguished by Type.
type StoreFavorite struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`

Type string `xorm:"varchar(20) index(idx_favorite)" json:"type"`
StoreOwner string `xorm:"varchar(100) index(idx_favorite)" json:"storeOwner"`
StoreName string `xorm:"varchar(100) index(idx_favorite)" json:"storeName"`
}

func IsValidFavoriteType(favoriteType string) bool {
return favoriteType == FavoriteTypeStar || favoriteType == FavoriteTypeWatch
}

func getStoreFavorite(user, favoriteType, storeOwner, storeName string) (*StoreFavorite, error) {
favorite := StoreFavorite{Owner: user, Type: favoriteType, StoreOwner: storeOwner, StoreName: storeName}
existed, err := adapter.engine.Get(&favorite)
if err != nil {
return nil, err
}
if !existed {
return nil, nil
}
return &favorite, nil
}

func IsStoreFavorited(user, favoriteType, storeOwner, storeName string) (bool, error) {
if user == "" {
return false, nil
}
favorite, err := getStoreFavorite(user, favoriteType, storeOwner, storeName)
if err != nil {
return false, err
}
return favorite != nil, nil
}

func GetStoreFavoriteCount(favoriteType, storeOwner, storeName string) (int64, error) {
return adapter.engine.Where("type = ? and store_owner = ? and store_name = ?", favoriteType, storeOwner, storeName).Count(&StoreFavorite{})
}

// ToggleStoreFavorite adds the favorite if absent, removes it if present, and
// returns whether the store is favorited after the operation.
func ToggleStoreFavorite(user, favoriteType, storeOwner, storeName string) (bool, error) {
existing, err := getStoreFavorite(user, favoriteType, storeOwner, storeName)
if err != nil {
return false, err
}

if existing != nil {
_, err = adapter.engine.Where("owner = ? and type = ? and store_owner = ? and store_name = ?", user, favoriteType, storeOwner, storeName).Delete(&StoreFavorite{})
if err != nil {
return false, err
}
return false, nil
}

favorite := &StoreFavorite{
Owner: user,
Name: util.GetRandomString(24),
CreatedTime: util.GetCurrentTimeWithMilli(),
Type: favoriteType,
StoreOwner: storeOwner,
StoreName: storeName,
}
_, err = adapter.engine.Insert(favorite)
if err != nil {
return false, err
}
return true, nil
}

// GetFavoredStores returns the stores the user has starred or watched, most
// recent first. Missing (deleted) target stores are skipped.
func GetFavoredStores(user, favoriteType string) ([]*Store, error) {
favorites := []*StoreFavorite{}
err := adapter.engine.Where("owner = ? and type = ?", user, favoriteType).Desc("created_time").Find(&favorites)
if err != nil {
return nil, err
}

stores := []*Store{}
for _, favorite := range favorites {
store, err := getStore(favorite.StoreOwner, favorite.StoreName)
if err != nil {
return nil, err
}
if store != nil {
stores = append(stores, store)
}
}
return stores, nil
}
13 changes: 13 additions & 0 deletions object/store_fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ func applyForkOwnerProviders(store *Store, targetOwner string) error {
return nil
}

// HasUserForkedStore reports whether targetOwner already owns a store forked
// from the given source store.
func HasUserForkedStore(targetOwner, srcOwner, srcName string) (bool, error) {
if targetOwner == "" || srcOwner == "" || srcName == "" {
return false, nil
}
count, err := adapter.engine.Where("owner = ? and forked_from_owner = ? and forked_from_name = ?", targetOwner, srcOwner, srcName).Count(&Store{})
if err != nil {
return false, err
}
return count > 0, nil
}

// ForkStore duplicates only the store configuration for targetOwner and records the source store.
func ForkStore(srcOwner, srcName, targetOwner string) (*Store, error) {
if srcOwner == "" || srcName == "" || targetOwner == "" {
Expand Down
3 changes: 3 additions & 0 deletions routers/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ func initAPI() {
beego.Router("/api/add-shared-store", &controllers.ApiController{}, "POST:AddSharedStore")
beego.Router("/api/fork-store", &controllers.ApiController{}, "POST:ForkStore")
beego.Router("/api/claim-store", &controllers.ApiController{}, "POST:ClaimStore")
beego.Router("/api/toggle-store-favorite", &controllers.ApiController{}, "POST:ToggleStoreFavorite")
beego.Router("/api/get-favored-stores", &controllers.ApiController{}, "GET:GetFavoredStores")
beego.Router("/api/get-store-favorite-status", &controllers.ApiController{}, "GET:GetStoreFavoriteStatus")

beego.Router("/api/get-global-providers", &controllers.ApiController{}, "GET:GetGlobalProviders")
beego.Router("/api/get-providers", &controllers.ApiController{}, "GET:GetProviders")
Expand Down
Loading
Loading