/* * 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, } }