408 lines
10 KiB
Go
408 lines
10 KiB
Go
/*
|
|
* Amsterdam Web Communities System
|
|
* Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
*
|
|
* SPDX-License-Identifier: MPL-2.0
|
|
*/
|
|
// The database package contains database management and storage logic.
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// PostLinkData is the structure holding the decoded parts of the post link.
|
|
type PostLinkData struct {
|
|
Community string
|
|
CommId int32
|
|
Conference string
|
|
Topic int16
|
|
FirstPost int32
|
|
LastPost int32
|
|
}
|
|
|
|
// NeedsDBVerification returns true if the post link data needs tro be varified against the database.
|
|
func (d *PostLinkData) NeedsDBVerification() bool {
|
|
return d.Community != "" || d.Conference != ""
|
|
}
|
|
|
|
// VerifyNames verifies the post link data against the database.
|
|
func (d *PostLinkData) VerifyNames(ctx context.Context) error {
|
|
commid := d.CommId
|
|
if d.Community != "" {
|
|
comm, err := AmGetCommunityByAlias(ctx, d.Community)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if comm == nil {
|
|
return errors.New("community alias not found")
|
|
}
|
|
commid = comm.Id
|
|
}
|
|
if d.Conference != "" {
|
|
conf, err := AmGetConferenceByAlias(ctx, commid, d.Conference)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if conf == nil {
|
|
return errors.New("conference alias not found")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AsString converts the post link data to a string reference.
|
|
func (d *PostLinkData) AsString() string {
|
|
var b strings.Builder
|
|
if d.Community != "" {
|
|
b.WriteString(d.Community)
|
|
b.WriteString("!")
|
|
}
|
|
wrote := false
|
|
if d.Conference != "" {
|
|
b.WriteString(d.Conference)
|
|
b.WriteString(".")
|
|
wrote = true
|
|
}
|
|
needDot := false
|
|
if d.Topic > 0 {
|
|
needDot = true
|
|
b.WriteString(fmt.Sprintf("%d", d.Topic))
|
|
if !wrote {
|
|
b.WriteString(".")
|
|
needDot = false
|
|
}
|
|
}
|
|
if d.FirstPost >= 0 {
|
|
s := ""
|
|
if d.LastPost < 0 || d.LastPost == d.FirstPost {
|
|
s = fmt.Sprintf("%d", d.FirstPost)
|
|
} else {
|
|
s = fmt.Sprintf("%d-%d", d.FirstPost, d.LastPost)
|
|
}
|
|
if needDot {
|
|
b.WriteString(".")
|
|
}
|
|
b.WriteString(s)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
/* Classify tells us what kind of post link this is and where we should interpret it from.
|
|
* Returns:
|
|
* String value indicating the scope of the link:
|
|
* "global" - Scope across the entire site.
|
|
* "community" - Scope within a community.
|
|
* "conference" - Scope within a conference.
|
|
* "topic" - Scope within a specific topic.
|
|
* Empty string - Null link.
|
|
* String value indicating what the link points to:
|
|
* "community" - Points to a community.
|
|
* "conference" - Points to a conference.
|
|
* "topic" - Points to a topic.
|
|
* "post" - Points to a single post within the topic.
|
|
* "postrange" - Points to a range of posts within the topic.
|
|
* "postopenrange" - Points to an open-ended range of posts within the topic.
|
|
* Empty string - Null link.
|
|
*/
|
|
func (d *PostLinkData) Classify() (string, string) {
|
|
if d.Community == "" {
|
|
if d.Conference == "" {
|
|
if d.Topic == -1 {
|
|
if d.FirstPost == -1 {
|
|
return "", ""
|
|
} else if d.LastPost == -1 {
|
|
return "topic", "postopenrange"
|
|
} else if d.LastPost == d.FirstPost {
|
|
return "topic", "post"
|
|
} else {
|
|
return "topic", "postrange"
|
|
}
|
|
} else {
|
|
if d.FirstPost == -1 {
|
|
return "conference", "topic"
|
|
} else if d.LastPost == -1 {
|
|
return "conference", "postopenrange"
|
|
} else if d.LastPost == d.FirstPost {
|
|
return "conference", "post"
|
|
} else {
|
|
return "conference", "postrange"
|
|
}
|
|
}
|
|
} else {
|
|
if d.Topic == -1 {
|
|
return "community", "conference"
|
|
} else {
|
|
if d.FirstPost == -1 {
|
|
return "community", "topic"
|
|
} else if d.LastPost == -1 {
|
|
return "community", "postopenrange"
|
|
} else if d.LastPost == d.FirstPost {
|
|
return "community", "post"
|
|
} else {
|
|
return "community", "postrange"
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if d.Conference == "" {
|
|
return "global", "community"
|
|
} else {
|
|
if d.Topic == -1 {
|
|
return "global", "conference"
|
|
} else {
|
|
if d.FirstPost == -1 {
|
|
return "global", "topic"
|
|
} else if d.LastPost == -1 {
|
|
return "global", "postopenrange"
|
|
} else if d.LastPost == d.FirstPost {
|
|
return "global", "post"
|
|
} else {
|
|
return "global", "postrange"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Maximum lengths of the components.
|
|
const (
|
|
maxLinkLength = 130
|
|
maxCommunityLength = 32
|
|
maxConferenceLength = 64
|
|
)
|
|
|
|
// validateCommunity validates the community name and saves it.
|
|
func validateCommunity(name string, rc *PostLinkData) error {
|
|
if len(name) > maxCommunityLength {
|
|
return errors.New("community alias is too long")
|
|
}
|
|
if !AmIsValidAmsterdamID(name) {
|
|
return errors.New("community alias is not a valid identifier")
|
|
}
|
|
rc.Community = name
|
|
return nil
|
|
}
|
|
|
|
// validateConference validates the conference name and saves it.
|
|
func validateConference(name string, rc *PostLinkData) error {
|
|
if len(name) > maxConferenceLength {
|
|
return errors.New("conference alias is too long")
|
|
}
|
|
if !AmIsValidAmsterdamID(name) {
|
|
return errors.New("conference alias is not a valid identifier")
|
|
}
|
|
rc.Conference = name
|
|
return nil
|
|
}
|
|
|
|
// decodeTopicNumber decodes the topic number and saves it.
|
|
func decodeTopicNumber(data string, rc *PostLinkData) error {
|
|
v, err := strconv.Atoi(data)
|
|
if err != nil {
|
|
return errors.New("invalid topic number reference")
|
|
}
|
|
if v > math.MaxInt16 {
|
|
return errors.New("topic number out of range")
|
|
}
|
|
rc.Topic = int16(v)
|
|
return nil
|
|
}
|
|
|
|
// decodePostRange decodes the post ranges (first and last post) and saves them.
|
|
func decodePostRange(data string, rc *PostLinkData) error {
|
|
pos := strings.IndexByte(data, '-')
|
|
var tempVal int32 = -1
|
|
if pos > 0 {
|
|
temp := data[:pos]
|
|
data = data[pos+1:]
|
|
v, err := strconv.Atoi(temp)
|
|
if err != nil {
|
|
return errors.New("invalid post number reference")
|
|
}
|
|
tempVal = int32(v)
|
|
|
|
if len(data) == 0 {
|
|
// range is open-ended (number-)
|
|
rc.FirstPost = tempVal
|
|
rc.LastPost = -1
|
|
return nil
|
|
}
|
|
} else if pos == 0 {
|
|
return errors.New("cannot have - at beginning of post range")
|
|
}
|
|
|
|
v2, err := strconv.Atoi(data)
|
|
if err != nil {
|
|
return errors.New("invalid post number reference")
|
|
}
|
|
rc.FirstPost = int32(v2)
|
|
if tempVal >= 0 {
|
|
if tempVal < rc.FirstPost {
|
|
// "frontwards" range - reorder the components
|
|
rc.LastPost = rc.FirstPost
|
|
rc.FirstPost = tempVal
|
|
} else {
|
|
// "backwards" range
|
|
rc.LastPost = tempVal
|
|
}
|
|
} else {
|
|
// a "range" of a single post
|
|
rc.LastPost = rc.FirstPost
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/* AmDecodePostLink decodes a post link and returns the complete breakdown of its components.
|
|
* Parameters:
|
|
* data - The post link to be decoded.
|
|
* Returns:
|
|
* Pointer to structure containing post link data, or nil.
|
|
* Standard Go error status.
|
|
*/
|
|
func AmDecodePostLink(data string) (*PostLinkData, error) {
|
|
if data == "" {
|
|
return nil, errors.New("empty string")
|
|
}
|
|
if len(data) > maxLinkLength {
|
|
return nil, errors.New("post link string too long")
|
|
}
|
|
rc := PostLinkData{
|
|
Community: "",
|
|
Conference: "",
|
|
Topic: -1,
|
|
FirstPost: -1,
|
|
LastPost: -1,
|
|
}
|
|
|
|
work := data
|
|
// First test: Bang
|
|
pos := strings.IndexByte(work, '!')
|
|
if pos > 0 {
|
|
err := validateCommunity(work[:pos], &rc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
work = work[pos+1:]
|
|
if len(work) == 0 {
|
|
return &rc, nil // community link
|
|
}
|
|
} else if pos == 0 {
|
|
return nil, errors.New("cannot have ! at beginning")
|
|
}
|
|
|
|
// Second test: Dot #1
|
|
pos = strings.IndexByte(work, '.')
|
|
if pos < 0 {
|
|
// no dots in here, must be either "postlink" or "community!conference"
|
|
var err error
|
|
if rc.Community == "" {
|
|
err = decodePostRange(work, &rc)
|
|
} else {
|
|
err = validateConference(work, &rc)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &rc, nil
|
|
}
|
|
|
|
// Peel off the initial substring before the dot.
|
|
confOrTopic := work[:pos]
|
|
work = work[pos+1:]
|
|
if len(work) == 0 {
|
|
// we had "conference." or "topic." or maybe "community!conference."
|
|
var err error
|
|
if rc.Community == "" {
|
|
// it's either "conference." or "topic." - try the latter first
|
|
err = decodeTopicNumber(confOrTopic, &rc)
|
|
if err != nil {
|
|
// it's not a topic number, try it as a conference name
|
|
err = validateConference(confOrTopic, &rc)
|
|
}
|
|
} else {
|
|
// it was "community!conference."
|
|
err = validateConference(confOrTopic, &rc)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &rc, nil
|
|
}
|
|
|
|
// Third test: Dot #2
|
|
pos = strings.IndexByte(work, '.')
|
|
if pos < 0 {
|
|
// we had "conference.topic" or "topic.posts" or maybe "community!conference.topic"
|
|
var err error
|
|
if rc.Community == "" {
|
|
// either "conference.topic" or "topic.posts"
|
|
isTopic := false
|
|
err = decodeTopicNumber(confOrTopic, &rc)
|
|
if err != nil {
|
|
// it's "conference.topic"
|
|
err = validateConference(confOrTopic, &rc)
|
|
isTopic = true
|
|
}
|
|
if err == nil {
|
|
if isTopic {
|
|
err = decodeTopicNumber(work, &rc)
|
|
} else {
|
|
err = decodePostRange(work, &rc)
|
|
}
|
|
}
|
|
} else {
|
|
// we have "community!conference.topic"
|
|
err = validateConference(confOrTopic, &rc)
|
|
if err == nil {
|
|
err = decodeTopicNumber(work, &rc)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &rc, nil
|
|
} else if pos == 0 {
|
|
return nil, errors.New("cannot have . at beginning of string")
|
|
}
|
|
|
|
// We definitely have "conference.topic.something" or "community!conference.topic.something"
|
|
err := validateConference(confOrTopic, &rc)
|
|
if err == nil {
|
|
err = decodeTopicNumber(work[:pos], &rc)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
work = work[pos+1:]
|
|
if len(work) == 0 {
|
|
// we had "conference.topic." or "communtiy!conference.topic.", those are both valid
|
|
return &rc, nil
|
|
}
|
|
err = decodePostRange(work, &rc) // the rest must be the post range
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &rc, nil
|
|
}
|
|
|
|
func AmCreatePostLinkContext(community string, commid int32, conference string, topic int16) *PostLinkData {
|
|
return &PostLinkData{
|
|
Community: community,
|
|
CommId: commid,
|
|
Conference: conference,
|
|
Topic: topic,
|
|
FirstPost: -1,
|
|
LastPost: -1,
|
|
}
|
|
}
|