Compare commits

..

1 Commits

Author SHA1 Message Date
69438a9d0c wip: restricted submissions endpoint 2025-04-03 15:06:52 -07:00
21 changed files with 228 additions and 446 deletions

4
Cargo.lock generated
View File

@@ -1297,9 +1297,9 @@ dependencies = [
[[package]]
name = "rbx_asset"
version = "0.3.4"
version = "0.3.3"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "681587db1bd628a7a9344c12008e65e11a10159831e00dcc85c089682cfcf2fb"
checksum = "91722b37549ded270f39556194ca03d03e08bd70674d239ec845765ed9e42b7d"
dependencies = [
"chrono",
"flate2",

View File

@@ -1,5 +1,5 @@
# Stage 1: Build
FROM registry.itzana.me/docker-proxy/golang:1.24 AS builder
FROM docker.io/golang:1.23 AS builder
# Set the working directory in the container
WORKDIR /app
@@ -14,7 +14,7 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o service ./cmd/maps-service/service.go
# Stage 2: Run
FROM registry.itzana.me/docker-proxy/alpine:3.21
FROM alpine
# Set up a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

View File

@@ -38,6 +38,7 @@ type Mapfixes interface {
IfStatusThenUpdateAndGet(ctx context.Context, id int64, statuses []model.MapfixStatus, values OptionalMap) (model.Mapfix, error)
Delete(ctx context.Context, id int64) error
List(ctx context.Context, filters OptionalMap, page model.Page, sort ListSort) ([]model.Mapfix, error)
ListRestricted(ctx context.Context, filters OptionalMap, page model.Page, sort ListSort, submitter int64) ([]model.Mapfix, error)
}
type Operations interface {
@@ -56,6 +57,7 @@ type Submissions interface {
IfStatusThenUpdateAndGet(ctx context.Context, id int64, statuses []model.SubmissionStatus, values OptionalMap) (model.Submission, error)
Delete(ctx context.Context, id int64) error
List(ctx context.Context, filters OptionalMap, page model.Page, sort ListSort) ([]model.Submission, error)
ListRestricted(ctx context.Context, filters OptionalMap, page model.Page, sort ListSort, submitter int64) ([]model.Submission, error)
}
type Scripts interface {

View File

@@ -130,3 +130,40 @@ func (env *Mapfixes) List(ctx context.Context, filters datastore.OptionalMap, pa
return maps, nil
}
func (env *Mapfixes) ListRestricted(ctx context.Context, filters datastore.OptionalMap, page model.Page, sort datastore.ListSort, submitter int64) ([]model.Mapfix, error) {
var maps []model.Mapfix
db := env.db
switch sort {
case datastore.ListSortDisabled:
// No sort
break
case datastore.ListSortDisplayNameAscending:
db=db.Order("display_name ASC")
break
case datastore.ListSortDisplayNameDescending:
db=db.Order("display_name DESC")
break
case datastore.ListSortDateAscending:
db=db.Order("created_at ASC")
break
case datastore.ListSortDateDescending:
db=db.Order("created_at DESC")
break
default:
return nil, datastore.ErrInvalidListSort
}
if err := db.
Where(filters.Map()).
Where("status_id NOT IN ? OR submitter = ? AND status_id IN ?",PrivateSubmissions,submitter,PrivateSubmissions).
Offset(int((page.Number - 1) * page.Size)).
Limit(int(page.Size)).
Find(&maps).Error; err != nil {
return nil, err
}
return maps, nil
}

View File

@@ -10,6 +10,13 @@ import (
"gorm.io/gorm/clause"
)
var(
PrivateSubmissions = []model.SubmissionStatus{
model.SubmissionStatusUnderConstruction,
model.SubmissionStatusChangesRequested,
}
)
type Submissions struct {
db *gorm.DB
}
@@ -130,3 +137,44 @@ func (env *Submissions) List(ctx context.Context, filters datastore.OptionalMap,
return maps, nil
}
func (env *Submissions) ListRestricted(ctx context.Context, filters datastore.OptionalMap, page model.Page, sort datastore.ListSort, submitter int64) ([]model.Submission, error) {
var maps []model.Submission
db := env.db
switch sort {
case datastore.ListSortDisabled:
// No sort
break
case datastore.ListSortDisplayNameAscending:
db=db.Order("display_name ASC")
break
case datastore.ListSortDisplayNameDescending:
db=db.Order("display_name DESC")
break
case datastore.ListSortDateAscending:
db=db.Order("created_at ASC")
break
case datastore.ListSortDateDescending:
db=db.Order("created_at DESC")
break
default:
return nil, datastore.ErrInvalidListSort
}
if err := db.
Where(filters.Map()).
// In order to see submissions,
// at least one of two criteria must be met:
// - You are the submitter
// - The submission is not under construction / changes requested
Where("status_id NOT IN ? OR submitter = ? AND status_id IN ?",PrivateSubmissions,submitter,PrivateSubmissions).
Offset(int((page.Number - 1) * page.Size)).
Limit(int(page.Size)).
Find(&maps).Error; err != nil {
return nil, err
}
return maps, nil
}

View File

@@ -7,7 +7,7 @@ edition = "2021"
submissions-api = { path = "api", features = ["internal"], default-features = false, registry = "strafesnet" }
async-nats = "0.40.0"
futures = "0.3.31"
rbx_asset = { version = "0.3.4", registry = "strafesnet" }
rbx_asset = { version = "0.3.3", registry = "strafesnet" }
rbx_binary = { version = "0.7.4", registry = "strafesnet"}
rbx_dom_weak = { version = "2.9.0", registry = "strafesnet"}
rbx_reflection_database = { version = "0.2.12", registry = "strafesnet"}

View File

@@ -1,6 +1,6 @@
# Using the `rust-musl-builder` as base image, instead of
# the official Rust toolchain
FROM registry.itzana.me/docker-proxy/clux/muslrust:1.86.0-stable AS chef
FROM docker.io/clux/muslrust:stable AS chef
USER root
RUN cargo install cargo-chef
WORKDIR /app
@@ -17,7 +17,7 @@ RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path r
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl --bin maps-validation
FROM registry.itzana.me/docker-proxy/alpine:3.21 AS runtime
FROM docker.io/alpine:latest AS runtime
RUN addgroup -S myuser && adduser -S myuser -G myuser
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/maps-validation /usr/local/bin/
USER myuser

View File

@@ -20,7 +20,6 @@ pub enum StartupError{
NatsConnect(async_nats::ConnectError),
NatsGetStream(async_nats::jetstream::context::GetStreamError),
NatsConsumer(async_nats::jetstream::stream::ConsumerError),
NatsConsumerUpdate(async_nats::jetstream::stream::ConsumerUpdateError),
NatsStream(async_nats::jetstream::consumer::StreamError),
}
impl std::fmt::Display for StartupError{
@@ -53,32 +52,17 @@ async fn main()->Result<(),StartupError>{
// nats
let nats_host=std::env::var("NATS_HOST").expect("NATS_HOST env required");
let nats_fut=async{
const STREAM_NAME:&str="maptest";
const DURABLE_NAME:&str="validation";
const FILTER_SUBJECT:&str="maptest.>";
let nats_config=async_nats::jetstream::consumer::pull::Config{
name:Some(DURABLE_NAME.to_owned()),
durable_name:Some(DURABLE_NAME.to_owned()),
filter_subject:FILTER_SUBJECT.to_owned(),
..Default::default()
};
let nasty=async_nats::connect(nats_host).await.map_err(StartupError::NatsConnect)?;
// use nats jetstream
let stream=async_nats::jetstream::new(nasty)
.get_stream(STREAM_NAME).await.map_err(StartupError::NatsGetStream)?;
let consumer=stream.get_or_create_consumer(DURABLE_NAME,nats_config.clone()).await.map_err(StartupError::NatsConsumer)?;
// check if config matches expected config
if consumer.cached_info().config.filter_subject!=FILTER_SUBJECT{
stream.update_consumer(nats_config).await.map_err(StartupError::NatsConsumerUpdate)?;
}
// only need messages
consumer.messages().await.map_err(StartupError::NatsStream)
async_nats::jetstream::new(nasty)
.get_stream("maptest").await.map_err(StartupError::NatsGetStream)?
.get_or_create_consumer("validation",async_nats::jetstream::consumer::pull::Config{
name:Some("validation".to_owned()),
durable_name:Some("validation".to_owned()),
filter_subject:"maptest.>".to_owned(),
..Default::default()
}).await.map_err(StartupError::NatsConsumer)?
.messages().await.map_err(StartupError::NatsStream)
};
let message_handler=message_handler::MessageHandler::new(cookie_context,group_id,api);

View File

@@ -32,7 +32,7 @@ fn hash_source(source:&str)->String{
#[allow(dead_code)]
#[derive(Debug)]
pub enum Error{
pub enum ValidateError{
ScriptFlaggedIllegalKeyword(String),
ScriptBlocked(Option<submissions_api::types::ScriptID>),
ScriptNotYetReviewed(Option<submissions_api::types::ScriptID>),
@@ -51,12 +51,12 @@ pub enum Error{
AssetUpload(rbx_asset::cookie::UploadError),
AssetCreate(rbx_asset::cookie::CreateError),
}
impl std::fmt::Display for Error{
impl std::fmt::Display for ValidateError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for Error{}
impl std::error::Error for ValidateError{}
#[allow(nonstandard_style)]
pub struct ValidateRequest{
@@ -88,15 +88,15 @@ impl From<crate::nats_types::ValidateSubmissionRequest> for ValidateRequest{
}
impl crate::message_handler::MessageHandler{
pub async fn validate_inner(&self,validate_info:ValidateRequest)->Result<(),Error>{
pub async fn validate_inner(&self,validate_info:ValidateRequest)->Result<(),ValidateError>{
// download map
let data=self.cookie_context.get_asset(rbx_asset::cookie::GetAssetRequest{
asset_id:validate_info.ModelID,
version:Some(validate_info.ModelVersion),
}).await.map_err(Error::ModelFileDownload)?;
}).await.map_err(ValidateError::ModelFileDownload)?;
// decode dom (slow!)
let mut dom=read_dom(&mut std::io::Cursor::new(data)).map_err(Error::ModelFileDecode)?;
let mut dom=read_dom(&mut std::io::Cursor::new(data)).map_err(ValidateError::ModelFileDecode)?;
/* VALIDATE MAP */
@@ -111,7 +111,7 @@ impl crate::message_handler::MessageHandler{
// immediately abort
// grab path to offending script
let path=get_partial_path(&dom,script);
return Err(Error::ScriptFlaggedIllegalKeyword(path));
return Err(ValidateError::ScriptFlaggedIllegalKeyword(path));
}
// associate a name and policy with the source code
// policy will be fetched from the database to replace the default policy
@@ -132,7 +132,7 @@ impl crate::message_handler::MessageHandler{
// fetch the script policy
let script_policy=self.api.get_script_policy_from_hash(submissions_api::types::HashRequest{
hash:hash.as_str(),
}).await.map_err(Error::ApiGetScriptPolicyFromHash)?;
}).await.map_err(ValidateError::ApiGetScriptPolicyFromHash)?;
// write the policy to the script_map, fetching the replacement code if necessary
if let Some(script_policy)=script_policy{
@@ -144,7 +144,7 @@ impl crate::message_handler::MessageHandler{
submissions_api::types::Policy::Replace=>{
let script=self.api.get_script(submissions_api::types::GetScriptRequest{
ScriptID:script_policy.ToScriptID,
}).await.map_err(Error::ApiGetScript)?;
}).await.map_err(ValidateError::ApiGetScript)?;
Policy::Replace(script.Source)
},
};
@@ -160,14 +160,14 @@ impl crate::message_handler::MessageHandler{
Source:source.as_str(),
ResourceType:resource_type,
ResourceID:Some(resource_id),
}).await.map_err(Error::ApiCreateScript)?;
}).await.map_err(ValidateError::ApiCreateScript)?;
// create a None policy (pending review by yours truly)
self.api.create_script_policy(submissions_api::types::CreateScriptPolicyRequest{
ToScriptID:script.ScriptID,
FromScriptID:script.ScriptID,
Policy:submissions_api::types::Policy::None,
}).await.map_err(Error::ApiCreateScriptPolicy)?;
}).await.map_err(ValidateError::ApiCreateScriptPolicy)?;
}
Ok(())
@@ -175,7 +175,7 @@ impl crate::message_handler::MessageHandler{
.await?;
// make the replacements
let mut modified=false;
let mut modified=true;
for &script_ref in &script_refs{
if let Some(script)=dom.get_by_ref_mut(script_ref){
if let Some(rbx_dom_weak::types::Variant::String(source))=script.properties.get_mut("Source"){
@@ -184,8 +184,8 @@ impl crate::message_handler::MessageHandler{
let hash=hash_source(source.as_str());
let script=self.api.get_script_from_hash(submissions_api::types::HashRequest{
hash:hash.as_str(),
}).await.map_err(Error::ApiGetScriptFromHash)?;
return Err(Error::ScriptBlocked(script.map(|s|s.ID)));
}).await.map_err(ValidateError::ApiGetScriptFromHash)?;
return Err(ValidateError::ScriptBlocked(script.map(|s|s.ID)));
},
None
|Some(Policy::None)
@@ -193,8 +193,8 @@ impl crate::message_handler::MessageHandler{
let hash=hash_source(source.as_str());
let script=self.api.get_script_from_hash(submissions_api::types::HashRequest{
hash:hash.as_str(),
}).await.map_err(Error::ApiGetScriptFromHash)?;
return Err(Error::ScriptNotYetReviewed(script.map(|s|s.ID)));
}).await.map_err(ValidateError::ApiGetScriptFromHash)?;
return Err(ValidateError::ScriptNotYetReviewed(script.map(|s|s.ID)));
},
Some(Policy::Allowed)=>(),
Some(Policy::Delete)=>{
@@ -211,17 +211,19 @@ impl crate::message_handler::MessageHandler{
}
}
println!("[Validator] Forcing model upload! modified=true");
// if the model was validated, the submission must be changed to use the modified model
let (validated_model_id,validated_model_version)=if modified{
if modified{
// serialize model (slow!)
let mut data=Vec::new();
let &[map_ref]=dom.root().children()else{
return Err(Error::ModelFileRootMustHaveOneChild);
return Err(ValidateError::ModelFileRootMustHaveOneChild);
};
rbx_binary::to_writer(&mut data,&dom,&[map_ref]).map_err(Error::ModelFileEncode)?;
rbx_binary::to_writer(&mut data,&dom,&[map_ref]).map_err(ValidateError::ModelFileEncode)?;
// upload a model lol
if let Some(model_id)=validate_info.ValidatedModelID{
let model_id=if let Some(model_id)=validate_info.ValidatedModelID{
// upload to existing id
let response=self.cookie_context.upload(rbx_asset::cookie::UploadRequest{
assetid:model_id,
@@ -230,13 +232,13 @@ impl crate::message_handler::MessageHandler{
ispublic:None,
allowComments:None,
groupId:None,
},data).await.map_err(Error::AssetUpload)?;
},data).await.map_err(ValidateError::AssetUpload)?;
(response.AssetId,response.AssetVersion)
response.AssetId
}else{
// grab the map instance from the map ref
// grab the map instance from the map re
let Some(map_instance)=dom.get_by_ref(map_ref)else{
return Err(Error::ModelFileChildRefIsNil);
return Err(ValidateError::ModelFileChildRefIsNil);
};
// create new model
let response=self.cookie_context.create(rbx_asset::cookie::CreateRequest{
@@ -245,31 +247,29 @@ impl crate::message_handler::MessageHandler{
ispublic:true,
allowComments:true,
groupId:None,
},data).await.map_err(Error::AssetCreate)?;
},data).await.map_err(ValidateError::AssetCreate)?;
(response.AssetId,response.AssetVersion)
response.AssetId
};
match validate_info.ResourceID{
ResourceID::Mapfix(mapfix_id)=>{
// update the mapfix to use the validated model
self.api.update_mapfix_validated_model(submissions_api::types::UpdateMapfixModelRequest{
MapfixID:mapfix_id,
ModelID:model_id,
ModelVersion:1, //TODO
}).await.map_err(ValidateError::ApiUpdateMapfixModel)?;
},
ResourceID::Submission(submission_id)=>{
// update the submission to use the validated model
self.api.update_submission_validated_model(submissions_api::types::UpdateSubmissionModelRequest{
SubmissionID:submission_id,
ModelID:model_id,
ModelVersion:1, //TODO
}).await.map_err(ValidateError::ApiUpdateSubmissionModel)?;
},
}
}else{
(validate_info.ModelID,validate_info.ModelVersion)
};
match validate_info.ResourceID{
ResourceID::Mapfix(mapfix_id)=>{
// update the mapfix to use the validated model
self.api.update_mapfix_validated_model(submissions_api::types::UpdateMapfixModelRequest{
MapfixID:mapfix_id,
ModelID:validated_model_id,
ModelVersion:validated_model_version,
}).await.map_err(Error::ApiUpdateMapfixModel)?;
},
ResourceID::Submission(submission_id)=>{
// update the submission to use the validated model
self.api.update_submission_validated_model(submissions_api::types::UpdateSubmissionModelRequest{
SubmissionID:submission_id,
ModelID:validated_model_id,
ModelVersion:validated_model_version,
}).await.map_err(Error::ApiUpdateSubmissionModel)?;
},
}
Ok(())

View File

@@ -1,4 +1,4 @@
FROM registry.itzana.me/docker-proxy/oven/bun:1.2.8
FROM oven/bun:latest
WORKDIR /app
@@ -10,4 +10,4 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN bun install
RUN bun run build
ENTRYPOINT ["bun", "run", "start"]
ENTRYPOINT ["bun", "run", "start"]

View File

@@ -1,11 +1,8 @@
import { Roles, RolesConstants } from "@/app/ts/Roles";
import { MapfixStatus } from "@/app/ts/Mapfix";
import { Button, ButtonOwnProps } from "@mui/material";
import { useState, useEffect } from "react";
type Actions = "Completed" | "Submit" | "Reject" | "Revoke"
type ApiActions = Lowercase<Actions> | "request-changes" | "trigger-validate" | "retry-validate" | "trigger-upload" | "reset-uploading" | "reset-validating"
type Review = Actions | "Request Changes" | "Accept" | "Validate" | "Upload" | "Reset Uploading (fix softlocked status)" | "Reset Validating (fix softlocked status)" | "Request Changes"
type ApiActions = Lowercase<Actions> | "trigger-validate" | "retry-validate" | "trigger-upload" | "reset-uploading" | "reset-validating"
type Review = Actions | "Accept" | "Validate" | "Upload" | "Reset Uploading (fix softlocked status)" | "Reset Validating (fix softlocked status)" | "Request Changes"
interface ReviewButton {
name: Review,
@@ -15,9 +12,7 @@ interface ReviewButton {
}
interface ReviewId {
mapfixId: string,
mapfixStatus: number,
mapfixSubmitter: number,
mapfixId: string
}
async function ReviewButtonClicked(action: ApiActions, mapfixId: string) {
@@ -50,6 +45,7 @@ function ReviewButton(props: ReviewButton) {
}
export default function ReviewButtons(props: ReviewId) {
const mapfixId = props.mapfixId
// When is each button visible?
// Multiple buttons can be visible at once.
// Action | Role | When Current Status is One of:
@@ -63,86 +59,16 @@ export default function ReviewButtons(props: ReviewId) {
// RequestChanges | Reviewer | Validated, Accepted, Submitted
// Upload | MapAdmin | Validated
// ResetUploading | MapAdmin | Uploading
const { mapfixId, mapfixStatus } = props;
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [rolesData, userData] = await Promise.all([
fetch("/api/session/roles").then(rolesResponse => rolesResponse.json()),
fetch("/api/session/user").then(userResponse => userResponse.json())
]);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
}
fetchData();
}, [mapfixId]);
if (loading) return <p>Loading...</p>;
const visibleButtons: ReviewButton[] = [];
const is_submitter = user === props.mapfixSubmitter;
if (is_submitter) {
if ([MapfixStatus.UnderConstruction, MapfixStatus.ChangesRequested].includes(mapfixStatus!)) {
visibleButtons.push({ name: "Submit", action: "submit", color: "info", mapfixId });
}
if ([MapfixStatus.Submitted, MapfixStatus.ChangesRequested].includes(mapfixStatus!)) {
visibleButtons.push({ name: "Revoke", action: "revoke", color: "info", mapfixId });
}
}
if (roles&RolesConstants.MapfixReview) {
// you can't review your own mapfix!
// note that this means there needs to be more than one person with MapfixReview
if (!is_submitter && mapfixStatus === MapfixStatus.Submitted) {
visibleButtons.push({ name: "Accept", action: "trigger-validate", color: "info", mapfixId });
visibleButtons.push({ name: "Reject", action: "reject", color: "error", mapfixId });
}
if (mapfixStatus === MapfixStatus.Accepted) {
visibleButtons.push({ name: "Validate", action: "retry-validate", color: "info", mapfixId });
}
if (mapfixStatus === MapfixStatus.Validating) {
visibleButtons.push({ name: "Reset Validating (fix softlocked status)", action: "reset-validating", color: "error", mapfixId });
}
// this button serves the same purpose as Revoke if you are both
// the map submitter and have MapfixReview when status is Submitted
if (
[MapfixStatus.Validated, MapfixStatus.Accepted].includes(mapfixStatus!)
|| !is_submitter && mapfixStatus == MapfixStatus.Submitted
) {
visibleButtons.push({ name: "Request Changes", action: "request-changes", color: "error", mapfixId });
}
}
if (roles&RolesConstants.MapfixUpload) {
if (mapfixStatus === MapfixStatus.Validated) {
visibleButtons.push({ name: "Upload", action: "trigger-upload", color: "info", mapfixId });
}
if (mapfixStatus === MapfixStatus.Uploading) {
visibleButtons.push({ name: "Reset Uploading (fix softlocked status)", action: "reset-uploading", color: "error", mapfixId });
}
}
return (
<section className="review-set">
{visibleButtons.length === 0 ? (
<p>No available actions</p>
) : (
visibleButtons.map((btn) => (
<ReviewButton key={btn.action} {...btn} />
))
)}
<ReviewButton color="info" name="Submit" action="submit" mapfixId={mapfixId}/>
<ReviewButton color="info" name="Revoke" action="revoke" mapfixId={mapfixId}/>
<ReviewButton color="info" name="Accept" action="trigger-validate" mapfixId={mapfixId}/>
<ReviewButton color="info" name="Validate" action="retry-validate" mapfixId={mapfixId}/>
<ReviewButton color="error" name="Reject" action="reject" mapfixId={mapfixId}/>
<ReviewButton color="info" name="Upload" action="trigger-upload" mapfixId={mapfixId}/>
<ReviewButton color="error" name="Reset Uploading (fix softlocked status)" action="reset-uploading" mapfixId={mapfixId}/>
<ReviewButton color="error" name="Reset Validating (fix softlocked status)" action="reset-validating" mapfixId={mapfixId}/>
</section>
);
)
}

View File

@@ -15,9 +15,7 @@ import { useState, useEffect } from "react";
import "./(styles)/page.scss";
interface ReviewId {
mapfixId: string,
mapfixStatus: number;
mapfixSubmitter: number,
mapfixId: string
}
function Ratings() {
@@ -48,7 +46,7 @@ function RatingArea(mapfix: ReviewId) {
<MapImage/>
</section>
<Ratings/>
<ReviewButtons mapfixId={mapfix.mapfixId} mapfixStatus={mapfix.mapfixStatus} mapfixSubmitter={mapfix.mapfixSubmitter}/>
<ReviewButtons mapfixId={mapfix.mapfixId}/>
</aside>
)
}
@@ -98,7 +96,7 @@ export default function MapfixInfoPage() {
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea mapfixId={dynamicId.mapfixId} mapfixStatus={mapfix.StatusID} mapfixSubmitter={mapfix.Submitter}/>
<RatingArea mapfixId={dynamicId.mapfixId}/>
<TitleAndComments name={mapfix.DisplayName} creator={mapfix.Creator} review={mapfix.StatusID} status_message={mapfix.StatusMessage} asset_id={mapfix.AssetID} comments={[]}/>
</section>
</main>

View File

@@ -1,28 +0,0 @@
import Image from "next/image";
import { MapInfo } from "@/app/ts/Map";
interface AssetID {
id: MapInfo["ID"];
}
function MapImage({ id }: AssetID) {
if (!id) {
return <p>Missing asset ID</p>;
}
const imageUrl = `/thumbnails/asset/${id}`;
return (
<Image
src={imageUrl}
alt="Map Thumbnail"
layout="responsive"
width={512}
height={512}
priority={true}
className="map-image"
/>
);
}
export { type AssetID, MapImage };

View File

@@ -13,7 +13,7 @@ interface MapfixPayload {
TargetAssetID: number;
}
interface IdResponse {
OperationID: number;
ID: number;
}
export default function MapfixInfoPage() {
@@ -53,7 +53,7 @@ export default function MapfixInfoPage() {
const id_response:IdResponse = await response.json();
// navigate to newly created mapfix
window.location.assign(`/operations/${id_response.OperationID}`)
window.location.assign(`/mapfixes/${id_response.ID}`)
} catch (error) {
console.error("Error submitting data:", error);

View File

@@ -1,83 +1,14 @@
"use client"
import { MapInfo } from "@/app/ts/Map";
import { MapImage } from "./_mapImage";
import Webpage from "@/app/_components/webpage";
import { useParams } from "next/navigation";
import Link from "next/link";
import { useState, useEffect } from "react";
interface ButtonProps {
name: string;
href: string;
}
function Button({ name, href }: ButtonProps) {
return (
<Link href={href}>
<button className="mt-6 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl shadow transition">
{name}
</button>
</Link>
);
}
export default function Map() {
const { mapId } = useParams();
const [map, setMap] = useState<MapInfo | null>(null);
const { mapId } = useParams<{mapId: string}>()
useEffect(() => {
async function getMap() {
const res = await fetch(`/api/maps/${mapId}`);
if (res.ok) {
setMap(await res.json());
}
}
getMap();
}, [mapId]);
if (!map) {
return (
<Webpage>
<div className="p-12 text-center text-gray-500">Loading map data...</div>
</Webpage>
);
}
return (
<Webpage>
<div className="max-w-4xl mx-auto p-6">
<div className="bg-white dark:bg-zinc-900 shadow-xl rounded-2xl p-8 space-y-8">
{/* Title */}
<h1 className="text-3xl font-bold text-center">{map.DisplayName}</h1>
{/* Image */}
<div className="w-full overflow-hidden rounded-xl border border-zinc-300 dark:border-zinc-700 shadow-md">
<MapImage id={map.ID} />
</div>
{/* Info grid */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-center text-zinc-700 dark:text-zinc-300">
<div>
<p className="text-sm font-medium text-zinc-500">Creator</p>
<p className="text-lg">{map.Creator}</p>
</div>
<div>
<p className="text-sm font-medium text-zinc-500">Game ID</p>
<p className="text-lg">{map.GameID}</p>
</div>
<div>
<p className="text-sm font-medium text-zinc-500">Release Date</p>
<p className="text-lg">{map.Date}</p>
</div>
</div>
{/* Button */}
<div className="text-center">
<Button name="Submit A Mapfix For This Map" href={`/maps/${mapId}/fix`} />
</div>
</div>
</div>
</Webpage>
);
return (
<Webpage>
<p>map { mapId }</p>
</Webpage>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { CircularProgress, Typography, Card, CardContent, Button } from "@mui/material";
import Webpage from "@/app/_components/webpage";
@@ -17,31 +17,23 @@ interface Operation {
}
export default function OperationStatusPage() {
const router = useRouter();
const { operationId } = useParams();
const router = useRouter();
const [operation, setOperation] = useState<Operation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [operation, setOperation] = useState<Operation | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!operationId) return;
const fetchOperation = async () => {
try {
const response = await fetch(`/api/operations/${operationId}`);
if (!response.ok) throw new Error("Failed to fetch operation");
const data: Operation = await response.json();
const data = await response.json();
setOperation(data);
if (data.Status !== 0 && intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
@@ -52,34 +44,39 @@ export default function OperationStatusPage() {
setLoading(false);
}
};
fetchOperation();
if (!intervalRef.current) {
intervalRef.current = setInterval(fetchOperation, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
const interval = setInterval(fetchOperation, 5000);
return () => clearInterval(interval);
}, [operationId]);
const getStatusText = (status: number) => {
const getStatusClass = (status: number) => {
switch (status) {
case 0:
return "Created";
case 1:
return "Completed";
case 2:
return "Failed";
default:
return "Unknown";
case 0:
return "created";
case 1:
return "completed";
case 2:
return "failed";
default:
return "";
}
};
const getStatusClass = (status: number) => getStatusText(status).toLowerCase();
const getStatusText = (status: number) => {
switch (status) {
case 0:
return "Created";
case 1:
return "Completed";
case 2:
return "Failed";
default:
return "Unknown";
}
};
return (
<Webpage>
<main className="operation-status">
@@ -99,13 +96,13 @@ export default function OperationStatusPage() {
<Typography>Owner: {operation.Owner}</Typography>
<Typography>Date: {new Date(operation.Date * 1000).toLocaleString()}</Typography>
<Typography>Path: {operation.Path}</Typography>
{operation.Status === 1 && (
<div className="submission-button">
<Button
variant="contained"
color="success"
onClick={() => router.push(operation.Path)}
onClick={() => router.push(`/submissions/${operation.OperationID}`)}
>
View Submission
</Button>
@@ -119,4 +116,4 @@ export default function OperationStatusPage() {
</main>
</Webpage>
);
}
}

View File

@@ -1,11 +1,8 @@
import { Roles, RolesConstants } from "@/app/ts/Roles";
import { SubmissionStatus } from "@/app/ts/Submission";
import { Button, ButtonOwnProps } from "@mui/material";
import { useState, useEffect } from "react";
type Actions = "Completed" | "Submit" | "Reject" | "Revoke"
type ApiActions = Lowercase<Actions> | "request-changes" | "trigger-validate" | "retry-validate" | "trigger-upload" | "reset-uploading" | "reset-validating"
type Review = Actions | "Request Changes" | "Accept" | "Validate" | "Upload" | "Reset Uploading (fix softlocked status)" | "Reset Validating (fix softlocked status)" | "Request Changes"
type ApiActions = Lowercase<Actions> | "trigger-validate" | "retry-validate" | "trigger-upload" | "reset-uploading" | "reset-validating"
type Review = Actions | "Accept" | "Validate" | "Upload" | "Reset Uploading (fix softlocked status)" | "Reset Validating (fix softlocked status)" | "Request Changes"
interface ReviewButton {
name: Review,
@@ -15,9 +12,7 @@ interface ReviewButton {
}
interface ReviewId {
submissionId: string,
submissionStatus: number,
submissionSubmitter: number,
submissionId: string
}
async function ReviewButtonClicked(action: ApiActions, submissionId: string) {
@@ -50,6 +45,7 @@ function ReviewButton(props: ReviewButton) {
}
export default function ReviewButtons(props: ReviewId) {
const submissionId = props.submissionId
// When is each button visible?
// Multiple buttons can be visible at once.
// Action | Role | When Current Status is One of:
@@ -63,86 +59,16 @@ export default function ReviewButtons(props: ReviewId) {
// RequestChanges | Reviewer | Validated, Accepted, Submitted
// Upload | MapAdmin | Validated
// ResetUploading | MapAdmin | Uploading
const { submissionId, submissionStatus } = props;
const [user, setUser] = useState<number|null>(null);
const [roles, setRoles] = useState<Roles>(RolesConstants.Empty);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [rolesData, userData] = await Promise.all([
fetch("/api/session/roles").then(rolesResponse => rolesResponse.json()),
fetch("/api/session/user").then(userResponse => userResponse.json())
]);
setRoles(rolesData.Roles);
setUser(userData.UserID);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
}
fetchData();
}, [submissionId]);
if (loading) return <p>Loading...</p>;
const visibleButtons: ReviewButton[] = [];
const is_submitter = user === props.submissionSubmitter;
if (is_submitter) {
if ([SubmissionStatus.UnderConstruction, SubmissionStatus.ChangesRequested].includes(submissionStatus!)) {
visibleButtons.push({ name: "Submit", action: "submit", color: "info", submissionId });
}
if ([SubmissionStatus.Submitted, SubmissionStatus.ChangesRequested].includes(submissionStatus!)) {
visibleButtons.push({ name: "Revoke", action: "revoke", color: "info", submissionId });
}
}
if (roles&RolesConstants.SubmissionReview) {
// you can't review your own submission!
// note that this means there needs to be more than one person with SubmissionReview
if (!is_submitter && submissionStatus === SubmissionStatus.Submitted) {
visibleButtons.push({ name: "Accept", action: "trigger-validate", color: "info", submissionId });
visibleButtons.push({ name: "Reject", action: "reject", color: "error", submissionId });
}
if (submissionStatus === SubmissionStatus.Accepted) {
visibleButtons.push({ name: "Validate", action: "retry-validate", color: "info", submissionId });
}
if (submissionStatus === SubmissionStatus.Validating) {
visibleButtons.push({ name: "Reset Validating (fix softlocked status)", action: "reset-validating", color: "error", submissionId });
}
// this button serves the same purpose as Revoke if you are both
// the map submitter and have SubmissionReview when status is Submitted
if (
[SubmissionStatus.Validated, SubmissionStatus.Accepted].includes(submissionStatus!)
|| !is_submitter && submissionStatus == SubmissionStatus.Submitted
) {
visibleButtons.push({ name: "Request Changes", action: "request-changes", color: "error", submissionId });
}
}
if (roles&RolesConstants.SubmissionUpload) {
if (submissionStatus === SubmissionStatus.Validated) {
visibleButtons.push({ name: "Upload", action: "trigger-upload", color: "info", submissionId });
}
if (submissionStatus === SubmissionStatus.Uploading) {
visibleButtons.push({ name: "Reset Uploading (fix softlocked status)", action: "reset-uploading", color: "error", submissionId });
}
}
return (
<section className="review-set">
{visibleButtons.length === 0 ? (
<p>No available actions</p>
) : (
visibleButtons.map((btn) => (
<ReviewButton key={btn.action} {...btn} />
))
)}
<ReviewButton color="info" name="Submit" action="submit" submissionId={submissionId}/>
<ReviewButton color="info" name="Revoke" action="revoke" submissionId={submissionId}/>
<ReviewButton color="info" name="Accept" action="trigger-validate" submissionId={submissionId}/>
<ReviewButton color="info" name="Validate" action="retry-validate" submissionId={submissionId}/>
<ReviewButton color="error" name="Reject" action="reject" submissionId={submissionId}/>
<ReviewButton color="info" name="Upload" action="trigger-upload" submissionId={submissionId}/>
<ReviewButton color="error" name="Reset Uploading (fix softlocked status)" action="reset-uploading" submissionId={submissionId}/>
<ReviewButton color="error" name="Reset Validating (fix softlocked status)" action="reset-validating" submissionId={submissionId}/>
</section>
);
)
}

View File

@@ -17,8 +17,6 @@ import "./(styles)/page.scss";
interface ReviewId {
submissionId: string;
assetId: number;
submissionStatus: number;
submissionSubmitter: number,
}
function Ratings() {
@@ -49,7 +47,7 @@ function RatingArea(submission: ReviewId) {
<MapImage id={submission.assetId}/>
</section>
<Ratings/>
<ReviewButtons submissionId={submission.submissionId} submissionStatus={submission.submissionStatus} submissionSubmitter={submission.submissionSubmitter}/>
<ReviewButtons submissionId={submission.submissionId}/>
</aside>
)
}
@@ -99,7 +97,7 @@ export default function SubmissionInfoPage() {
<Webpage>
<main className="map-page-main">
<section className="review-section">
<RatingArea assetId={submission.AssetID} submissionId={dynamicId.submissionId} submissionStatus={submission.StatusID} submissionSubmitter={submission.Submitter}/>
<RatingArea assetId={submission.AssetID} submissionId={dynamicId.submissionId}/>
<TitleAndComments name={submission.DisplayName} creator={submission.Creator} review={submission.StatusID} status_message={submission.StatusMessage} asset_id={submission.AssetID} comments={[]}/>
</section>
</main>

View File

@@ -1,11 +0,0 @@
interface MapInfo {
readonly ID: number,
readonly DisplayName: string,
readonly Creator: string,
readonly GameID: number,
readonly Date: number,
}
export {
type MapInfo
}

View File

@@ -8,7 +8,6 @@ const enum MapfixStatus {
Uploading = 6,
Uploaded = 7,
Rejected = 8,
// MapfixStatus does not have a Released state
}
interface MapfixInfo {

View File

@@ -1,25 +0,0 @@
type Roles = number;
// Constants
const RolesConstants = {
All: -1 as Roles,
SubmissionUpload: 1 << 6 as Roles,
SubmissionReview: 1 << 5 as Roles,
SubmissionRelease: 1 << 4 as Roles,
ScriptWrite: 1 << 3 as Roles,
MapfixUpload: 1 << 2 as Roles,
MapfixReview: 1 << 1 as Roles,
MapDownload: 1 << 0 as Roles,
Empty: 0 as Roles,
};
// Operations
function hasRole(flags: Roles, role: Roles): boolean {
return (flags & role) === role;
}
export {
type Roles,
RolesConstants,
hasRole,
};