authorize( $course_id, $actor ); $url = trim( $url ); if ( ( $attachment_id > 0 ) === ( '' !== $url ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'file' ) ); } if ( $attachment_id > 0 ) { \RoosterLMS\Content\AttachmentAccess::check( $actor->is_trusted() ? null : $actor->user_id, $attachment_id, 'file' ); } if ( '' !== $url ) { $url = esc_url_raw( $url, array( 'http', 'https' ) ); if ( '' === $url ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'file' ) ); } } $title = trim( sanitize_text_field( $title ) ); if ( '' === $title && $attachment_id > 0 ) { $title = trim( (string) get_post_field( 'post_title', $attachment_id, 'raw' ) ); } $title = mb_substr( $title, 0, 190 ); if ( '' === $title ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'title' ) ); } $id = $this->insert( $course_id, $attachment_id, $url, $title, $access, $actor->user_id ); Services::strings()->register( TextObject::COURSE_FILE, $id, $title, 'title' ); Services::strings()->created_in( TextObject::COURSE_FILE, $id, $title, $lang, Actor::system(), 'title' ); return $this->get( $id ); } public function insert( int $course_id, int $attachment_id, string $url, string $title, FileAccess $access, int $user_id ): int { $id = (int) Transaction::run( static function () use ( $course_id, $attachment_id, $url, $title, $access, $user_id ): int { Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', Db::table( 'courses' ), $course_id ); $rows = Db::col( 'SELECT sort FROM %i WHERE course_id = %d FOR UPDATE', Db::table( 'course_files' ), $course_id ); if ( count( $rows ) >= self::MAX_PER_COURSE ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'limit' ) ); } $sort = array() === $rows ? 0 : max( array_map( 'intval', $rows ) ) + 1; if ( $attachment_id > 0 ) { Db::query( 'INSERT INTO %i (course_id, attachment_id, url, title, access, sort, added_by, added_at) VALUES (%d, %d, NULL, %s, %d, %d, %d, %s)', Db::table( 'course_files' ), $course_id, $attachment_id, $title, $access->value, $sort, max( 0, $user_id ), Transaction::now() ); } else { Db::query( 'INSERT INTO %i (course_id, attachment_id, url, title, access, sort, added_by, added_at) VALUES (%d, NULL, %s, %s, %d, %d, %d, %s)', Db::table( 'course_files' ), $course_id, $url, $title, $access->value, $sort, max( 0, $user_id ), Transaction::now() ); } return Db::insert_id(); } ); $this->forget(); return $id; } public function update( int $id, ?string $title, ?FileAccess $access, Actor $actor, string $lang = '' ): CourseFile { $old = $this->get( $id ); if ( null === $old ) { DomainError::raise( ErrorCode::NOT_FOUND ); } $this->authorize( $old->course_id, $actor ); $stored = $old->title; if ( null !== $title ) { $title = mb_substr( trim( sanitize_text_field( $title ) ), 0, 190 ); if ( ! Strings::writes_stored( $lang ) ) { Services::strings()->save_translation( TextObject::COURSE_FILE, $id, $old->title, $lang, $title, Actor::system(), 'title' ); } else { if ( '' === $title ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'title' ) ); } $stored = $title; } } $accessv = $access ?? $old->access; Transaction::run( static function () use ( $id, $stored, $accessv ): void { Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', Db::table( 'course_files' ), $id ); Db::query( 'UPDATE %i SET title = %s, access = %d WHERE id = %d', Db::table( 'course_files' ), $stored, $accessv->value, $id ); } ); $this->forget(); if ( $stored !== $old->title ) { Services::strings()->register( TextObject::COURSE_FILE, $id, $stored, 'title', $old->title ); } return $this->get( $id ); } public function remove( int $id, Actor $actor ): bool { $old = $this->get( $id ); if ( null === $old ) { return false; } $this->authorize( $old->course_id, $actor ); $this->delete_rows( array( $id ) ); return true; } public function delete_rows( array $ids ): void { $ids = array_values( array_filter( array_map( 'intval', $ids ), static fn( int $id ): bool => $id > 0 ) ); if ( array() === $ids ) { return; } Transaction::run( static function () use ( $ids ): void { global $wpdb; Db::query( SqlPart::of( 'DELETE FROM %i WHERE 1 = 1', Db::table( 'course_files' ) )->add( SqlPart::and_in( 'id', $ids ) ) ); Db::query( SqlPart::of( 'DELETE FROM %i WHERE object_type = %d', Db::table( 'import_map' ), \RoosterLMS\Import\ImportObject::COURSE_FILE->value )->add( SqlPart::and_in( 'object_id', $ids ) ) ); Services::strings()->delete_objects( TextObject::COURSE_FILE, $ids ); } ); $this->forget(); } public function reorder( int $course_id, array $ids, Actor $actor ): void { $this->authorize( $course_id, $actor ); $ids = array_values( array_unique( array_map( 'intval', $ids ) ) ); Transaction::run( static function () use ( $course_id, $ids ): void { $have = array_map( 'intval', Db::col( 'SELECT id FROM %i WHERE course_id = %d ORDER BY sort, id FOR UPDATE', Db::table( 'course_files' ), $course_id ) ); $order = array_merge( array_values( array_intersect( $ids, $have ) ), array_values( array_diff( $have, $ids ) ) ); foreach ( $order as $i => $id ) { Db::query( 'UPDATE %i SET sort = %d WHERE id = %d', Db::table( 'course_files' ), $i, $id ); } } ); $this->forget(); } public function get( int $id ): ?CourseFile { $row = Db::row( 'SELECT * FROM %i WHERE id = %d', Db::table( 'course_files' ), $id ); return null === $row ? null : CourseFile::from_row( $row ); } public function for_course( int $course_id ): array { if ( ! isset( $this->memo[ $course_id ] ) ) { $this->memo[ $course_id ] = array_map( array( CourseFile::class, 'from_row' ), Db::rows( 'SELECT * FROM %i WHERE course_id = %d ORDER BY sort, id', Db::table( 'course_files' ), $course_id ) ); } return $this->memo[ $course_id ]; } public function for_viewer( int $course_id, int $user_id ): array { $all = $this->for_course( $course_id ); if ( array() === $all ) { return array(); } if ( $this->can_manage( $course_id, $user_id ) ) { return $all; } if ( ! Services::gate()->can_view_course( $user_id, $course_id )->allowed ) { return array(); } $enrolled = self::enrolled( $course_id, $user_id ); return array_values( array_filter( $all, static fn( CourseFile $f ): bool => FileAccess::EVERYONE === $f->access || $enrolled ) ); } public function can_view( int $id, int $user_id ): bool { $file = $this->get( $id ); if ( null === $file ) { return false; } foreach ( $this->for_viewer( $file->course_id, $user_id ) as $visible ) { if ( $visible->id === $id ) { return true; } } return false; } public function titles( array $files, string $lang = '' ): array { $defaults = array(); foreach ( $files as $file ) { $defaults[ $file->id ] = $file->title; } return array() === $defaults ? array() : Services::strings()->translate_many( TextObject::COURSE_FILE, $defaults, '' !== $lang ? $lang : Names::display_language(), 'title' ); } public function can_manage( int $course_id, int $user_id ): bool { return Services::course_details()->can_edit( $course_id, $user_id ); } private static function enrolled( int $course_id, int $user_id ): bool { if ( $user_id <= 0 || Services::gate()->launch_locked( $user_id, $course_id ) ) { return false; } $enrolment = Services::enrolment_repo()->for_user( $user_id, array( $course_id ) )[ $course_id ] ?? null; return ( null !== $enrolment && $enrolment->is_active() ) || null !== Services::gate()->staff_access( $user_id, $course_id ); } public static function attachment_deleted( $attachment_id ): void { $attachment_id = (int) $attachment_id; if ( $attachment_id > 0 ) { AttachmentRefs::guard( 'course files', $attachment_id, static fn( int $id ) => Services::course_files()->forget_attachment( $id ) ); } } public function forget_attachment( int $attachment_id ): int { $ids = array_map( 'intval', Db::col( 'SELECT id FROM %i WHERE attachment_id = %d', Db::table( 'course_files' ), $attachment_id ) ); $this->delete_rows( $ids ); return count( $ids ); } public function forget(): void { $this->memo = array(); } private function authorize( int $course_id, Actor $actor ): void { if ( null === Services::course_repo()->get( $course_id ) ) { DomainError::raise( ErrorCode::NOT_FOUND ); } if ( $actor->is_trusted() ) { return; } if ( $actor->is_learner() || ! $this->can_manage( $course_id, $actor->user_id ) ) { DomainError::raise( ErrorCode::FORBIDDEN ); } } }