forked from TrueCloudLab/frostfs-node
[#1423] session: Upgrade SDK package
Signed-off-by: Leonard Lyubich <leonard@nspcc.ru>
This commit is contained in:
parent
dda56f1319
commit
4c8ec20e32
41 changed files with 740 additions and 663 deletions
|
@ -337,7 +337,7 @@ func PutObject(prm PutObjectPrm) (*PutObjectRes, error) {
|
|||
wrt.MarkLocal()
|
||||
}
|
||||
|
||||
wrt.WithXHeaders(prm.xHeadersPrm()...)
|
||||
wrt.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
if wrt.WriteHeader(*prm.hdr) {
|
||||
sz := prm.hdr.PayloadSize()
|
||||
|
@ -437,7 +437,7 @@ func DeleteObject(prm DeleteObjectPrm) (*DeleteObjectRes, error) {
|
|||
delPrm.WithBearerToken(*prm.bearerToken)
|
||||
}
|
||||
|
||||
delPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
delPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
cliRes, err := prm.cli.ObjectDelete(context.Background(), delPrm)
|
||||
if err != nil {
|
||||
|
@ -517,7 +517,7 @@ func GetObject(prm GetObjectPrm) (*GetObjectRes, error) {
|
|||
getPrm.MarkLocal()
|
||||
}
|
||||
|
||||
getPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
getPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
rdr, err := prm.cli.ObjectGetInit(context.Background(), getPrm)
|
||||
if err != nil {
|
||||
|
@ -599,7 +599,7 @@ func HeadObject(prm HeadObjectPrm) (*HeadObjectRes, error) {
|
|||
cliPrm.MarkLocal()
|
||||
}
|
||||
|
||||
cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
res, err := prm.cli.ObjectHead(context.Background(), cliPrm)
|
||||
if err != nil {
|
||||
|
@ -664,7 +664,7 @@ func SearchObjects(prm SearchObjectsPrm) (*SearchObjectsRes, error) {
|
|||
cliPrm.MarkLocal()
|
||||
}
|
||||
|
||||
cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
rdr, err := prm.cli.ObjectSearchInit(context.Background(), cliPrm)
|
||||
if err != nil {
|
||||
|
@ -775,7 +775,7 @@ func HashPayloadRanges(prm HashPayloadRangesPrm) (*HashPayloadRangesRes, error)
|
|||
cliPrm.WithBearerToken(*prm.bearerToken)
|
||||
}
|
||||
|
||||
cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
res, err := prm.cli.ObjectHash(context.Background(), cliPrm)
|
||||
if err != nil {
|
||||
|
@ -841,7 +841,7 @@ func PayloadRange(prm PayloadRangePrm) (*PayloadRangeRes, error) {
|
|||
cliPrm.SetOffset(prm.rng.GetOffset())
|
||||
cliPrm.SetLength(prm.rng.GetLength())
|
||||
|
||||
cliPrm.WithXHeaders(prm.xHeadersPrm()...)
|
||||
cliPrm.WithXHeaders(prm.xHeaders...)
|
||||
|
||||
rdr, err := prm.cli.ObjectRangeInit(context.Background(), cliPrm)
|
||||
if err != nil {
|
||||
|
|
|
@ -30,15 +30,6 @@ func (x *containerIDPrm) SetContainerID(id *cid.ID) {
|
|||
x.cnrID = id
|
||||
}
|
||||
|
||||
type sessionTokenPrm struct {
|
||||
sessionToken *session.Token
|
||||
}
|
||||
|
||||
// SetSessionToken sets the token of the session within which the request should be sent.
|
||||
func (x *sessionTokenPrm) SetSessionToken(tok *session.Token) {
|
||||
x.sessionToken = tok
|
||||
}
|
||||
|
||||
type bearerTokenPrm struct {
|
||||
bearerToken *bearer.Token
|
||||
}
|
||||
|
@ -76,12 +67,13 @@ func (x *payloadWriterPrm) SetPayloadWriter(wrt io.Writer) {
|
|||
|
||||
type commonObjectPrm struct {
|
||||
commonPrm
|
||||
sessionTokenPrm
|
||||
bearerTokenPrm
|
||||
|
||||
sessionToken *session.Object
|
||||
|
||||
local bool
|
||||
|
||||
xHeaders []*session.XHeader
|
||||
xHeaders []string
|
||||
}
|
||||
|
||||
// SetTTL sets request TTL value.
|
||||
|
@ -90,19 +82,11 @@ func (x *commonObjectPrm) SetTTL(ttl uint32) {
|
|||
}
|
||||
|
||||
// SetXHeaders sets request X-Headers.
|
||||
func (x *commonObjectPrm) SetXHeaders(hs []*session.XHeader) {
|
||||
func (x *commonObjectPrm) SetXHeaders(hs []string) {
|
||||
x.xHeaders = hs
|
||||
}
|
||||
|
||||
func (x commonObjectPrm) xHeadersPrm() (res []string) {
|
||||
if x.xHeaders != nil {
|
||||
res = make([]string, len(x.xHeaders)*2)
|
||||
|
||||
for i := range x.xHeaders {
|
||||
res[2*i] = x.xHeaders[i].Key()
|
||||
res[2*i+1] = x.xHeaders[i].Value()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
// SetSessionToken sets the token of the session within which the request should be sent.
|
||||
func (x *commonObjectPrm) SetSessionToken(tok *session.Object) {
|
||||
x.sessionToken = tok
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/nspcc-dev/neofs-sdk-go/bearer"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
@ -35,23 +35,11 @@ func ReadBearerToken(cmd *cobra.Command, flagname string) *bearer.Token {
|
|||
|
||||
// ReadSessionToken reads session token as JSON file with session token
|
||||
// from path provided in a specified flag.
|
||||
func ReadSessionToken(cmd *cobra.Command, flag string) *session.Token {
|
||||
func ReadSessionToken(cmd *cobra.Command, dst json.Unmarshaler, fPath string) {
|
||||
// try to read session token from file
|
||||
var tok *session.Token
|
||||
|
||||
path, err := cmd.Flags().GetString(flag)
|
||||
ExitOnErr(cmd, "", err)
|
||||
|
||||
if path == "" {
|
||||
return tok
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := os.ReadFile(fPath)
|
||||
ExitOnErr(cmd, "could not open file with session token: %w", err)
|
||||
|
||||
tok = session.NewToken()
|
||||
err = tok.UnmarshalJSON(data)
|
||||
ExitOnErr(cmd, "could not ummarshal session token from file: %w", err)
|
||||
|
||||
return tok
|
||||
err = dst.UnmarshalJSON(data)
|
||||
ExitOnErr(cmd, "could not unmarshal session token from file: %w", err)
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import (
|
|||
"github.com/nspcc-dev/neofs-sdk-go/object"
|
||||
addressSDK "github.com/nspcc-dev/neofs-sdk-go/object/address"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/policy"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
subnetid "github.com/nspcc-dev/neofs-sdk-go/subnet/id"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/user"
|
||||
versionSDK "github.com/nspcc-dev/neofs-sdk-go/version"
|
||||
|
@ -158,26 +159,33 @@ It will be stored in sidechain when inner ring will accepts it.`,
|
|||
nonce, err := parseNonce(containerNonce)
|
||||
common.ExitOnErr(cmd, "", err)
|
||||
|
||||
tok := common.ReadSessionToken(cmd, sessionTokenFlag)
|
||||
key := key.GetOrGenerate(cmd)
|
||||
|
||||
var idOwner *user.ID
|
||||
cnr := container.New()
|
||||
var tok *session.Container
|
||||
|
||||
if idOwner = tok.OwnerID(); idOwner == nil {
|
||||
idOwner = new(user.ID)
|
||||
user.IDFromKey(idOwner, key.PublicKey)
|
||||
if sessionTokenPath != "" {
|
||||
tok = new(session.Container)
|
||||
common.ReadSessionToken(cmd, tok, sessionTokenPath)
|
||||
|
||||
issuer := tok.Issuer()
|
||||
cnr.SetOwnerID(&issuer)
|
||||
cnr.SetSessionToken(tok)
|
||||
} else {
|
||||
var idOwner user.ID
|
||||
user.IDFromKey(&idOwner, key.PublicKey)
|
||||
|
||||
cnr.SetOwnerID(&idOwner)
|
||||
}
|
||||
|
||||
ver := versionSDK.Current()
|
||||
|
||||
cnr := container.New()
|
||||
cnr.SetVersion(&ver)
|
||||
cnr.SetPlacementPolicy(placementPolicy)
|
||||
cnr.SetBasicACL(basicACL)
|
||||
cnr.SetAttributes(attributes)
|
||||
cnr.SetNonceUUID(nonce)
|
||||
cnr.SetSessionToken(tok)
|
||||
cnr.SetOwnerID(idOwner)
|
||||
|
||||
var (
|
||||
putPrm internalclient.PutContainerPrm
|
||||
|
@ -223,7 +231,12 @@ Only owner of the container has a permission to remove container.`,
|
|||
id, err := parseContainerID(containerID)
|
||||
common.ExitOnErr(cmd, "", err)
|
||||
|
||||
tok := common.ReadSessionToken(cmd, sessionTokenFlag)
|
||||
var tok *session.Container
|
||||
|
||||
if sessionTokenPath != "" {
|
||||
tok = new(session.Container)
|
||||
common.ReadSessionToken(cmd, tok, sessionTokenPath)
|
||||
}
|
||||
|
||||
var (
|
||||
delPrm internalclient.DeleteContainerPrm
|
||||
|
@ -234,7 +247,7 @@ Only owner of the container has a permission to remove container.`,
|
|||
delPrm.SetContainer(*id)
|
||||
|
||||
if tok != nil {
|
||||
delPrm.SetSessionToken(*tok)
|
||||
delPrm.WithinSession(*tok)
|
||||
}
|
||||
|
||||
_, err = internalclient.DeleteContainer(delPrm)
|
||||
|
@ -412,7 +425,12 @@ Container ID in EACL table will be substituted with ID from the CLI.`,
|
|||
|
||||
eaclTable := common.ReadEACL(cmd, eaclPathFrom)
|
||||
|
||||
tok := common.ReadSessionToken(cmd, sessionTokenFlag)
|
||||
var tok *session.Container
|
||||
|
||||
if sessionTokenPath != "" {
|
||||
tok = new(session.Container)
|
||||
common.ReadSessionToken(cmd, tok, sessionTokenPath)
|
||||
}
|
||||
|
||||
eaclTable.SetCID(*id)
|
||||
eaclTable.SetSessionToken(tok)
|
||||
|
|
|
@ -314,7 +314,7 @@ func init() {
|
|||
|
||||
type clientKeySession interface {
|
||||
clientWithKey
|
||||
SetSessionToken(*session.Token)
|
||||
SetSessionToken(*session.Object)
|
||||
}
|
||||
|
||||
func prepareSessionPrm(cmd *cobra.Command, addr *addressSDK.Address, prms ...clientKeySession) {
|
||||
|
@ -339,65 +339,54 @@ func prepareSessionPrmWithOwner(
|
|||
) {
|
||||
cli := internalclient.GetSDKClientByFlag(cmd, key, commonflags.RPC)
|
||||
|
||||
var sessionToken *session.Token
|
||||
var tok session.Object
|
||||
if tokenPath, _ := cmd.Flags().GetString(sessionTokenFlag); len(tokenPath) != 0 {
|
||||
data, err := ioutil.ReadFile(tokenPath)
|
||||
common.ExitOnErr(cmd, "can't read session token: %w", err)
|
||||
|
||||
sessionToken = session.NewToken()
|
||||
if err := sessionToken.Unmarshal(data); err != nil {
|
||||
err = sessionToken.UnmarshalJSON(data)
|
||||
if err := tok.Unmarshal(data); err != nil {
|
||||
err = tok.UnmarshalJSON(data)
|
||||
common.ExitOnErr(cmd, "can't unmarshal session token: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
sessionToken, err = sessionCli.CreateSession(cli, ownerID, sessionTokenLifetime)
|
||||
common.ExitOnErr(cmd, "", err)
|
||||
err := sessionCli.CreateSession(&tok, cli, sessionTokenLifetime)
|
||||
common.ExitOnErr(cmd, "create session: %w", err)
|
||||
}
|
||||
|
||||
for i := range prms {
|
||||
objectContext := session.NewObjectContext()
|
||||
switch prms[i].(type) {
|
||||
case *internalclient.GetObjectPrm:
|
||||
objectContext.ForGet()
|
||||
tok.ForVerb(session.VerbObjectGet)
|
||||
case *internalclient.HeadObjectPrm:
|
||||
objectContext.ForHead()
|
||||
tok.ForVerb(session.VerbObjectHead)
|
||||
case *internalclient.PutObjectPrm:
|
||||
objectContext.ForPut()
|
||||
tok.ForVerb(session.VerbObjectPut)
|
||||
case *internalclient.DeleteObjectPrm:
|
||||
objectContext.ForDelete()
|
||||
tok.ForVerb(session.VerbObjectDelete)
|
||||
case *internalclient.SearchObjectsPrm:
|
||||
objectContext.ForSearch()
|
||||
tok.ForVerb(session.VerbObjectSearch)
|
||||
case *internalclient.PayloadRangePrm:
|
||||
objectContext.ForRange()
|
||||
tok.ForVerb(session.VerbObjectRange)
|
||||
case *internalclient.HashPayloadRangesPrm:
|
||||
objectContext.ForRangeHash()
|
||||
tok.ForVerb(session.VerbObjectRangeHash)
|
||||
default:
|
||||
panic("invalid client parameter type")
|
||||
}
|
||||
objectContext.ApplyTo(addr)
|
||||
|
||||
tok := session.NewToken()
|
||||
tok.SetID(sessionToken.ID())
|
||||
tok.SetSessionKey(sessionToken.SessionKey())
|
||||
tok.SetOwnerID(sessionToken.OwnerID())
|
||||
tok.SetContext(objectContext)
|
||||
tok.SetExp(sessionToken.Exp())
|
||||
tok.SetIat(sessionToken.Iat())
|
||||
tok.SetNbf(sessionToken.Nbf())
|
||||
tok.ApplyTo(*addr)
|
||||
|
||||
err := tok.Sign(key)
|
||||
err := tok.Sign(*key)
|
||||
common.ExitOnErr(cmd, "session token signing: %w", err)
|
||||
|
||||
prms[i].SetClient(cli)
|
||||
prms[i].SetSessionToken(tok)
|
||||
prms[i].SetSessionToken(&tok)
|
||||
}
|
||||
}
|
||||
|
||||
type objectPrm interface {
|
||||
bearerPrm
|
||||
SetTTL(uint32)
|
||||
SetXHeaders([]*session.XHeader)
|
||||
SetXHeaders([]string)
|
||||
}
|
||||
|
||||
func prepareObjectPrm(cmd *cobra.Command, prms ...objectPrm) {
|
||||
|
|
|
@ -22,7 +22,6 @@ import (
|
|||
"github.com/nspcc-dev/neofs-node/pkg/util/gendoc"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/bearer"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/client"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/user"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
@ -188,8 +187,8 @@ func userFromString(id *user.ID, s string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func parseXHeaders() []*session.XHeader {
|
||||
xs := make([]*session.XHeader, 0, len(xHeaders))
|
||||
func parseXHeaders() []string {
|
||||
xs := make([]string, 0, 2*len(xHeaders))
|
||||
|
||||
for i := range xHeaders {
|
||||
kv := strings.SplitN(xHeaders[i], "=", 2)
|
||||
|
@ -197,11 +196,7 @@ func parseXHeaders() []*session.XHeader {
|
|||
panic(fmt.Errorf("invalid X-Header format: %s", xHeaders[i]))
|
||||
}
|
||||
|
||||
x := session.NewXHeader()
|
||||
x.SetKey(kv[0])
|
||||
x.SetValue(kv[1])
|
||||
|
||||
xs = append(xs, x)
|
||||
xs = append(xs, kv[0], kv[1])
|
||||
}
|
||||
|
||||
return xs
|
||||
|
|
|
@ -4,13 +4,14 @@ import (
|
|||
"fmt"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
internalclient "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/client"
|
||||
"github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/commonflags"
|
||||
"github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/key"
|
||||
"github.com/nspcc-dev/neofs-node/pkg/network"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/client"
|
||||
neofsecdsa "github.com/nspcc-dev/neofs-sdk-go/crypto/ecdsa"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/user"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
@ -66,10 +67,9 @@ func createSession(cmd *cobra.Command, _ []string) error {
|
|||
lifetime = lfArg
|
||||
}
|
||||
|
||||
var ownerID user.ID
|
||||
user.IDFromKey(&ownerID, privKey.PublicKey)
|
||||
var tok session.Object
|
||||
|
||||
tok, err := CreateSession(c, &ownerID, lifetime)
|
||||
err = CreateSession(&tok, c, lifetime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -78,11 +78,11 @@ func createSession(cmd *cobra.Command, _ []string) error {
|
|||
|
||||
if toJSON, _ := cmd.Flags().GetBool(jsonFlag); toJSON {
|
||||
data, err = tok.MarshalJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode session token JSON: %w", err)
|
||||
}
|
||||
} else {
|
||||
data, err = tok.Marshal()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't marshal token: %w", err)
|
||||
data = tok.Marshal()
|
||||
}
|
||||
|
||||
filename, _ := cmd.Flags().GetString(outFlag)
|
||||
|
@ -92,15 +92,18 @@ func createSession(cmd *cobra.Command, _ []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// CreateSession returns newly created session token with the specified owner and lifetime.
|
||||
// `Issued-At` and `Not-Valid-Before` fields are set to current epoch.
|
||||
func CreateSession(c *client.Client, owner *user.ID, lifetime uint64) (*session.Token, error) {
|
||||
// CreateSession opens a new communication with NeoFS storage node using client connection.
|
||||
// The session is expected to be maintained by the storage node during the given
|
||||
// number of epochs.
|
||||
//
|
||||
// Fills ID, lifetime and session key.
|
||||
func CreateSession(dst *session.Object, c *client.Client, lifetime uint64) error {
|
||||
var netInfoPrm internalclient.NetworkInfoPrm
|
||||
netInfoPrm.SetClient(c)
|
||||
|
||||
ni, err := internalclient.NetworkInfo(netInfoPrm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't fetch network info: %w", err)
|
||||
return fmt.Errorf("can't fetch network info: %w", err)
|
||||
}
|
||||
|
||||
cur := ni.NetworkInfo().CurrentEpoch()
|
||||
|
@ -112,16 +115,30 @@ func CreateSession(c *client.Client, owner *user.ID, lifetime uint64) (*session.
|
|||
|
||||
sessionRes, err := internalclient.CreateSession(sessionPrm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't open session: %w", err)
|
||||
return fmt.Errorf("can't open session: %w", err)
|
||||
}
|
||||
|
||||
tok := session.NewToken()
|
||||
tok.SetID(sessionRes.ID())
|
||||
tok.SetSessionKey(sessionRes.SessionKey())
|
||||
tok.SetOwnerID(owner)
|
||||
tok.SetExp(exp)
|
||||
tok.SetIat(cur)
|
||||
tok.SetNbf(cur)
|
||||
binIDSession := sessionRes.ID()
|
||||
|
||||
return tok, nil
|
||||
var keySession neofsecdsa.PublicKey
|
||||
|
||||
err = keySession.Decode(sessionRes.SessionKey())
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode public session key: %w", err)
|
||||
}
|
||||
|
||||
var idSession uuid.UUID
|
||||
|
||||
err = idSession.UnmarshalBinary(binIDSession)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode session ID: %w", err)
|
||||
}
|
||||
|
||||
dst.SetID(idSession)
|
||||
dst.SetNbf(cur)
|
||||
dst.SetIat(cur)
|
||||
dst.SetExp(exp)
|
||||
dst.SetAuthKey(&keySession)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/common"
|
||||
"github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/commonflags"
|
||||
"github.com/nspcc-dev/neofs-node/cmd/neofs-cli/internal/key"
|
||||
"github.com/nspcc-dev/neofs-sdk-go/session"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
@ -29,10 +31,19 @@ func initSignSessionCmd() {
|
|||
}
|
||||
|
||||
func signSessionToken(cmd *cobra.Command, _ []string) {
|
||||
stok := common.ReadSessionToken(cmd, signFromFlag)
|
||||
fPath, err := cmd.Flags().GetString(signFromFlag)
|
||||
common.ExitOnErr(cmd, "", err)
|
||||
|
||||
if fPath == "" {
|
||||
common.ExitOnErr(cmd, "", errors.New("missing session token flag"))
|
||||
}
|
||||
|
||||
var stok session.Object
|
||||
common.ReadSessionToken(cmd, &stok, signFromFlag)
|
||||
|
||||
pk := key.GetOrGenerate(cmd)
|
||||
|
||||
err := stok.Sign(pk)
|
||||
err = stok.Sign(*pk)
|
||||
common.ExitOnErr(cmd, "can't sign token: %w", err)
|
||||
|
||||
data, err := stok.MarshalJSON()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue